31 Commits

Author SHA1 Message Date
2ec4be624b Theme editor 2025-08-18 23:36:36 +02:00
369704a87c Better h2 title 2025-08-18 22:21:59 +02:00
330e467dcb Better global UI 2025-08-18 20:05:08 +02:00
305042b365 Lumeex scroll 2 top merge 2025-08-18 13:11:34 +02:00
7a7876e5ef Better form 2025-08-18 12:29:45 +02:00
8cb81a74cf Refreshed site.yaml 2025-08-17 23:51:15 +02:00
c193fd49aa Better UI form 2025-08-17 23:49:36 +02:00
5d863223e3 Form UI 1st step 2025-08-17 22:44:54 +02:00
a8f3c1b497 All fields OK 2025-08-17 14:26:31 +02:00
b74f1bb350 Site-info front 2025-08-17 13:52:25 +02:00
5a6f08644a Fixed responsive issue 2025-08-17 12:38:29 +02:00
031ff62168 Better css 2025-08-17 11:26:21 +02:00
b56d03303e Remove all button 2025-08-17 10:57:59 +02:00
d3484a4b50 Confirmation modale for deletion 2025-08-17 00:37:55 +02:00
9d37b0a60f Better tag UI 2025-08-16 22:57:56 +02:00
080eb2593d Header 2025-08-16 21:40:38 +02:00
73a0dd0ce6 Better UI 2025-08-16 16:30:56 +02:00
97645b06fa Most used tags 2025-08-16 14:08:34 +02:00
142c042b86 Better ui tag system 2025-08-16 13:14:04 +02:00
041db66b3d Reworked flow 2025-08-16 11:17:15 +02:00
1b0b228273 Gallery front 2025-08-16 10:29:51 +02:00
41450837f2 Versionning 2025-08-13 22:14:16 +00:00
4edeb8709a Click on photo tag -> Photo on top + scroll to top 2025-08-13 22:10:03 +00:00
6fc573c510 Merge pull request 'Slimmer docker image + fifo file check' (#7) from docker into main
Reviewed-on: #7
2025-08-13 18:21:24 +02:00
43c007c1fe Slimmer docker image + fifo file check 2025-08-13 16:20:46 +00:00
dfbd532efd Merge pull request 'Docker support' (#6) from docker into main
Reviewed-on: #6
2025-08-13 17:47:25 +02:00
efe1bbca29 new ensure_dir logic
+ better logs
+ docker files
+ new docs
2025-08-13 15:45:33 +00:00
7e1a5e659f Merge pull request 'errors' (#5) from errors into main
Reviewed-on: #5
2025-08-12 19:35:54 +02:00
f5a5aefd09 gallery.py starter + rename builder.py 2025-08-12 17:31:46 +00:00
f76420b2c3 favicon log + better robot.txt + modular starter 2025-08-12 17:08:31 +00:00
3901bf8acf Fixed typo 2025-08-11 17:15:46 +00:00
34 changed files with 3586 additions and 437 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
.*
!.sh
!.gitignore
output/
__pycache__/

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY ./src/ ./src/
COPY ./build.py ./build.py
COPY ./gallery.py ./gallery.py
COPY ./config /app/default
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
ENTRYPOINT ["/app/entrypoint.sh"]

View File

@ -23,9 +23,8 @@ The project includes two thoughtfully designed themes—one modern, one minimali
## 📌 Table of Contents
- [✨ Features](#-features)
- [🐍 Python Installation](#-python-installation)
- [⚙️ Configuration](#-configuration)
- [✨ Features](#-features)
- [🐳 Docker or 🐍 Python Installation](#-docker-or--python-installation)
## ✨ Features
@ -57,28 +56,6 @@ The project includes two thoughtfully designed themes—one modern, one minimali
- *(Optional)* Converts images to WebP format for optimized performance
- Outputs a complete static website ready to deploy on any web server
## 🐍 Python Installation
Run the Python scripts directly with the following prerequisites:
### Prerequisites
- Git
- Python 3.11 or above
### Installation Steps
```sh
git clone https://git.djeex.fr/Djeex/lumeex.git
cd lumeex
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
You are now ready to use Lumeex!
### ⚙️ Configuration
For comprehensive documentation on configuration options, customization, and demos, please visit:
## 🐳 Docker or 🐍 Python Installation
For comprehensive documentation on installation, configuration options, customization, and demos, please visit:
https://lumeex.djeex.fr

187
build.py
View File

@ -1,187 +1,6 @@
import logging
from datetime import datetime
from pathlib import Path
from shutil import copyfile
from PIL import Image
from src.py.utils import ensure_dir, copy_assets, load_yaml, load_theme_config
from src.py.css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
from src.py.image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
from src.py.html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
# Configure logging to display only the messages
logging.basicConfig(level=logging.INFO, format='%(message)s')
# Define key directories used throughout the script
SRC_DIR = Path.cwd()
BUILD_DIR = SRC_DIR / "output"
TEMPLATE_DIR = SRC_DIR / "src/templates"
IMG_DIR = SRC_DIR / "config/photos"
JS_DIR = SRC_DIR / "src/public/js"
STYLE_DIR = SRC_DIR / "src/public/style"
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
SITE_FILE = SRC_DIR / "config/site.yaml"
THEMES_DIR = SRC_DIR / "config/themes"
def build():
logging.info("🚀 Starting build...")
ensure_dir(BUILD_DIR)
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
# Defining build vars
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
site_vars = load_yaml(SITE_FILE)
gallery_vars = load_yaml(GALLERY_FILE)
build_section = site_vars.get("build", {})
theme_name = site_vars.get("build", {}).get("theme", "default")
theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
fonts_dir = theme_dir / "fonts"
theme_css_path = theme_dir / "theme.css"
canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
canonical_home = f"{canonical_url}/"
canonical_legals = f"{canonical_url}/legals/"
# Copying theme.css if existing
if theme_css_path.exists():
dest_theme_css = BUILD_DIR / "style" / "theme.css"
dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
copyfile(theme_css_path, dest_theme_css)
theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
else:
theme_css = ""
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
# Converting and resizing images if enabled
convert_images = build_section.get("convert_images", True)
resize_images = build_section.get("resize_images", True)
logging.info(f"[~] convert_images = {convert_images}")
logging.info(f"[~] resize_images = {resize_images}")
hero_images = gallery_vars.get("hero", {}).get("images", [])
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
if convert_images:
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
else:
copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
if "hero" not in site_vars:
site_vars["hero"] = {} # Initialize an empty hero section
# Adding menu
menu_html = "\n".join(
f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
for item in site_vars.get("menu", {}).get("items", [])
)
site_vars["hero"]["menu_items"] = menu_html
if "footer" in site_vars:
site_vars["footer"]["menu_items"] = menu_html
# Adding Google fonts if existing
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
logging.info(f"[✓] Google Fonts link generated:\n{google_fonts_link}")
# Generating thumbnail
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
if thumbnail_path:
src_thumb = IMG_DIR / thumbnail_path
dest_thumb_dir = BUILD_DIR / "img" / "social"
dest_thumb_dir.mkdir(parents=True, exist_ok=True)
dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
try:
img = Image.open(src_thumb)
img = img.convert("RGB")
img = img.resize((1200, 630), Image.LANCZOS)
img.save(dest_thumb, "JPEG", quality=90)
logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
except Exception as e:
logging.error(f"[✗] Failed to process thumbnail: {e}")
else:
logging.warning("[~] No thumbnail found in social section")
# Defining head variables
head_vars = dict(site_vars.get("info", {}))
head_vars.update(theme_vars.get("colors", {}))
head_vars.update(site_vars.get("social", {}))
head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
head_vars["google_fonts_link"] = google_fonts_link
head_vars["font_preloads"] = "\n".join(preload_links)
head_vars["theme_css"] = theme_css
head_vars["build_date"] = build_date
head_vars["canonical"] = canonical_home
# Render the home page
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
gallery_html = render_gallery_images(gallery_images)
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
signature = f"<!-- Build with Lumeex v1.2 | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
body = f"""
<body>
<div class="page-loader"><div class="spinner"></div></div>
{hero}
{gallery}
{footer}
</body>
"""
output_file = BUILD_DIR / "index.html"
with open(output_file, "w", encoding="utf-8") as f:
f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
logging.info(f"[✓] HTML generated: {output_file}")
# Rendering legals page
head_vars["canonical"] = canonical_legals
legals_vars = site_vars.get("legals", {})
if legals_vars:
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
ip_paragraphs = legals_vars.get("intellectual_property", [])
paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
legals_context = {
"hoster_name": legals_vars.get("hoster_name", ""),
"hoster_adress": legals_vars.get("hoster_adress", ""),
"hoster_contact": legals_vars.get("hoster_contact", ""),
"intellectual_property": paragraphs_html,
}
legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
output_legals = BUILD_DIR / "legals" / "index.html"
output_legals.parent.mkdir(parents=True, exist_ok=True)
with open(output_legals, "w", encoding="utf-8") as f:
f.write(legals_html)
logging.info(f"[✓] Legals page generated: {output_legals}")
else:
logging.warning("[~] No legals section found in site.yaml")
# Hero carrousel generator
if hero_images:
generate_gallery_json_from_images(hero_images, BUILD_DIR)
else:
logging.warning("[~] No hero images found, skipping JSON generation.")
# Sitemap and robot.txt generator
site_info = site_vars.get("info", {})
canonical_url = site_info.get("canonical", "").rstrip("/")
if canonical_url:
allowed_pages = ["/", "/legals/"]
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
else:
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
logging.info("✅ Build complete.")
from src.py.builder.site_builder import build
if __name__ == "__main__":
build()
logging.basicConfig(level=logging.INFO, format="%(message)s")
build()

View File

@ -1,7 +1,4 @@
# Use gallery.py to automatically add photos stored in your /config/photos/gallery folder
# Add tags to your photos as shown below
# remove the # before [] if you removed all images to use gallery.py again
hero:
images: []
gallery:
images: []
images: []

View File

@ -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:

View File

@ -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

78
docker/.sh/entrypoint.sh Normal file
View File

@ -0,0 +1,78 @@
#!/bin/bash
set -e
CYAN="\033[1;36m"
NC="\033[0m"
copy_default_config() {
echo "Checking configuration directory..."
if [ ! -d "/app/config" ]; then
mkdir -p /app/config
fi
echo "Checking if default config files need to be copied..."
files_copied=false
for file in /app/default/*; do
filename=$(basename "$file")
target="/app/config/$filename"
if [ ! -e "$target" ]; then
echo "Copying default config file: $filename"
cp -r "$file" "$target"
files_copied=true
fi
done
if [ "$files_copied" = true ]; then
echo "Default configuration files copied successfully."
else
echo "No default files needed to be copied."
fi
}
start_server() {
# Clean up old FIFOs
[ -p /tmp/build_logs_fifo ] && rm /tmp/build_logs_fifo
[ -p /tmp/build_logs_fifo2 ] && rm /tmp/build_logs_fifo2
mkfifo /tmp/build_logs_fifo
mkfifo /tmp/build_logs_fifo2
cat /tmp/build_logs_fifo >&2 &
cat /tmp/build_logs_fifo2 >&2 &
echo "Starting HTTP server on port 3000..."
python3 -u -m http.server 3000 -d /app/output &
SERVER_PID=$!
trap "echo 'Stopping server...'; kill -TERM $SERVER_PID 2>/dev/null; wait $SERVER_PID; exit 0" SIGINT SIGTERM
wait $SERVER_PID
}
if [ $# -eq 0 ]; then
echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}"
echo -e "${CYAN}${NC} Lum${CYAN}eex${NC} - Version 1.3.1${NC} ${CYAN}${NC}"
echo -e "${CYAN}├───────────────────────────────────────────┤${NC}"
echo -e "${CYAN}${NC} Source: https://git.djeex.fr/Djeex/lumeex ${CYAN}${NC}"
echo -e "${CYAN}${NC} Mirror: https://github.com/Djeex/lumeex ${CYAN}${NC}"
echo -e "${CYAN}${NC} Documentation: https://lumeex.djeex.fr ${CYAN}${NC}"
echo -e "${CYAN}╰───────────────────────────────────────────╯${NC}"
copy_default_config
start_server
fi
case "$1" in
build)
echo "Running build.py..."
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
;;
gallery)
echo "Running gallery.py..."
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
;;
*)
echo "Unknown command: $1"
exec "$@"
;;
esac

View File

@ -0,0 +1,10 @@
services:
lumeex:
container_name: lmx
build: ..
volumes:
- ../config:/app/config # mount config directory
- ../output:/app/output # mount output directory
ports:
- "3000:3000"

View File

@ -1,113 +1,7 @@
import yaml
import os
from pathlib import Path
# YAML file paths
GALLERY_YAML = "config/gallery.yaml"
# Image directories
GALLERY_DIR = Path("config/photos/gallery")
HERO_DIR = Path("config/photos/hero")
def load_yaml(path):
print(f"[→] Loading {path}...")
if not os.path.exists(path):
print(f"[✗] File not found: {path}")
return {}
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
images = data.get("images", []) or []
print(f"[✓] Loaded {len(images)} image(s) from {path}")
return data
def save_yaml(data, path):
with open(path, "w", encoding="utf-8") as f:
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
print(f"[✓] Saved updated YAML to {path}")
def get_all_image_paths(directory):
return sorted([
str(p.relative_to(directory.parent)).replace("\\", "/")
for p in directory.rglob("*")
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
])
def update_gallery():
print("\n=== Updating gallery.yaml (gallery section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
gallery_section = gallery.get("gallery", {})
# Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
gallery_images = gallery_section.get("images", [])
all_images = set(get_all_image_paths(GALLERY_DIR))
known_images = {img["src"] for img in gallery_images}
# Add new images
new_images = [
{"src": path, "tags": []}
for path in all_images
if path not in known_images
]
if new_images:
gallery_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
# Update the 'gallery' section with the modified 'images' list
gallery_section["images"] = gallery_images
gallery["gallery"] = gallery_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (gallery)")
def update_hero():
print("\n=== Updating gallery.yaml (hero section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
hero_section = gallery.get("hero", {})
# Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
hero_images = hero_section.get("images", [])
all_images = set(get_all_image_paths(HERO_DIR))
known_images = {img["src"] for img in hero_images}
# Add new images
new_images = [
{"src": path}
for path in all_images
if path not in known_images
]
if new_images:
hero_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
hero_images = [img for img in hero_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
# Update the 'hero' section with the modified 'images' list
hero_section["images"] = hero_images
gallery["hero"] = hero_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (hero)")
import logging
from src.py.builder.gallery_builder import update_gallery, update_hero
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(message)s")
update_gallery()
update_hero()
update_hero()

View File

@ -1,2 +1,3 @@
pyyaml
pillow
pillow
flask

View File

@ -17,24 +17,11 @@ const setupLoader = () => {
window.addEventListener('load', () => {
setTimeout(() => {
const loader = document.querySelector('.page-loader');
if (loader) {
loader.classList.add('hidden');
}
if (loader) loader.classList.add('hidden');
}, 50);
});
};
// Gallery randomizer to shuffle gallery sections on page load
const shuffleGallery = () => {
const gallery = document.querySelector('.gallery');
if (!gallery) return;
const sections = Array.from(gallery.querySelectorAll('.section'));
while (sections.length) {
const randomIndex = Math.floor(Math.random() * sections.length);
gallery.appendChild(sections.splice(randomIndex, 1)[0]);
}
};
// Hero background randomizer
const randomizeHeroBackground = () => {
const heroBg = document.querySelector(".hero-background");
@ -65,32 +52,87 @@ const randomizeHeroBackground = () => {
.catch(console.error);
};
// Gallery randomizer to shuffle gallery sections on page load
const shuffleGallery = () => {
const gallery = document.querySelector('.gallery');
if (!gallery) return;
const sections = Array.from(gallery.querySelectorAll('.section'));
while (sections.length) {
const randomIndex = Math.floor(Math.random() * sections.length);
gallery.appendChild(sections.splice(randomIndex, 1)[0]);
}
};
// Tags filter functionality
const setupTagFilter = () => {
const galleryContainer = document.querySelector('#gallery');
const allSections = document.querySelectorAll('.section[data-tags]');
const allTags = document.querySelectorAll('.tag');
let activeTags = [];
let lastClickedTag = null; // remembers the last clicked tag
let lastClickedSection = null; // remembers the last clicked section (photo)
const applyFilter = () => {
let filteredSections = [];
let matchingSection = null;
allSections.forEach((section) => {
const sectionTags = section.dataset.tags.toLowerCase().split(/\s+/);
const hasAllTags = activeTags.every((tag) => sectionTags.includes(tag));
section.style.display = hasAllTags ? '' : 'none';
if (hasAllTags) {
if (lastClickedSection === section) {
matchingSection = section;
} else {
filteredSections.push(section);
}
}
});
// 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);
});
}
// Update tag styles
allTags.forEach((tagEl) => {
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
tagEl.classList.toggle('active', activeTags.includes(tagText));
});
// Update the URL
const base = window.location.pathname;
const query = activeTags.length > 0 ? `?tag=${activeTags.join(',')}` : '';
window.history.pushState({}, '', base + query);
// Scroll to the gallery
if (galleryContainer) {
galleryContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
allTags.forEach((tagEl) => {
tagEl.addEventListener('click', () => {
const tagText = tagEl.textContent.replace('#', '').toLowerCase();
activeTags = activeTags.includes(tagText)
? activeTags.filter((t) => t !== tagText)
: [...activeTags, tagText];
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);
} else {
activeTags.push(tagText);
}
applyFilter();
});
});
@ -100,19 +142,17 @@ const setupTagFilter = () => {
const urlTags = params.get('tag');
if (urlTags) {
activeTags = urlTags.split(',').map((t) => t.toLowerCase());
lastClickedTag = activeTags[activeTags.length - 1] || null;
lastClickedSection = null; // No section selected from URL
applyFilter();
}
});
};
// Disable right-click context menu and image dragging
// Disable right click and drag
const disableRightClickAndDrag = () => {
document.addEventListener("contextmenu", (e) => e.preventDefault());
document.addEventListener("dragstart", (e) => {
if (e.target.tagName === "IMG") {
e.preventDefault();
}
});
document.addEventListener('contextmenu', (e) => e.preventDefault());
document.addEventListener('dragstart', (e) => e.preventDefault());
};
// Scroll-to-top button functionality
@ -149,4 +189,4 @@ document.addEventListener("DOMContentLoaded", () => {
fixNavSeparators();
});
window.addEventListener('resize', fixNavSeparators);
window.addEventListener('resize', fixNavSeparators);

View File

@ -333,8 +333,12 @@ h2 {
font-size: 22px;
}
/* Sections */
.gallery {
padding-top: 15px;
}
/* Sections */
.section {
max-width: 1140px;
margin:auto;
@ -508,4 +512,9 @@ h2 {
padding-left: 0;
margin-top: 60px;
}
.gallery {
margin: 10% 5% 0 5%;
padding-top: 15px;
}
}

View File

@ -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 = []

View File

@ -0,0 +1,114 @@
import yaml
import os
from pathlib import Path
# YAML file paths
GALLERY_YAML = "config/gallery.yaml"
# Image directories
GALLERY_DIR = Path("config/photos/gallery")
HERO_DIR = Path("config/photos/hero")
def load_yaml(path):
"""Load gallery config .yaml file"""
print(f"[→] Loading {path}...")
if not os.path.exists(path):
print(f"[✗] File not found: {path}")
return {}
with open(path, "r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
images = data.get("images", []) or []
print(f"[✓] Loaded {len(images)} image(s) from {path}")
return data
def save_yaml(data, path):
"""Save modified gallery config .yaml file"""
with open(path, "w", encoding="utf-8") as f:
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
print(f"[✓] Saved updated YAML to {path}")
def get_all_image_paths(directory):
"""Get the path to record for builded site"""
return sorted([
str(p.relative_to(directory.parent)).replace("\\", "/")
for p in directory.rglob("*")
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
])
def update_gallery():
"""Update the gallery photo list"""
print("\n=== Updating gallery.yaml (gallery section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
gallery_section = gallery.get("gallery", {})
# Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
gallery_images = gallery_section.get("images", [])
all_images = set(get_all_image_paths(GALLERY_DIR))
known_images = {img["src"] for img in gallery_images}
# Add new images
new_images = [
{"src": path, "tags": []}
for path in all_images
if path not in known_images
]
if new_images:
gallery_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
# Update the 'gallery' section with the modified 'images' list
gallery_section["images"] = gallery_images
gallery["gallery"] = gallery_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (gallery)")
def update_hero():
"""Update the hero photo list"""
print("\n=== Updating gallery.yaml (hero section) ===")
gallery = load_yaml(GALLERY_YAML)
# Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
hero_section = gallery.get("hero", {})
# Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
hero_images = hero_section.get("images", [])
all_images = set(get_all_image_paths(HERO_DIR))
known_images = {img["src"] for img in hero_images}
# Add new images
new_images = [
{"src": path}
for path in all_images
if path not in known_images
]
if new_images:
hero_images.extend(new_images)
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
# Remove deleted images
deleted_images = known_images - all_images
if deleted_images:
hero_images = [img for img in hero_images if img["src"] not in deleted_images]
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
# Update the 'hero' section with the modified 'images' list
hero_section["images"] = hero_images
gallery["hero"] = hero_section
save_yaml(gallery, GALLERY_YAML)
if not new_images and not deleted_images:
print("[✓] No changes to gallery.yaml (hero)")

View File

@ -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,19 +38,35 @@ 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: *"]
for path in allowed_paths:
robots_lines.append(f"Allow: {path}")
# Block everything by default
robots_lines.append("Disallow: /")
# Explicitly allow certain paths
for path in allowed_paths:
if not path.startswith("/"):
path = "/" + path
robots_lines.append(f"Allow: {path}")
robots_lines.append("")
robots_lines.append(f"Sitemap: {canonical_url}/sitemap.xml")
robots_lines.append(f"Sitemap: {canonical_url.rstrip('/')}/sitemap.xml")
content = "\n".join(robots_lines)
output_path = output_dir / "robots.txt"
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
logging.info(f"[✓] robots.txt generated at {output_path}")
output_path = Path(output_dir) / "robots.txt"
try:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
logging.info(f"[✓] robots.txt generated at {output_path}")
except Exception as e:
logging.error(f"[✗] Failed to write robots.txt: {e}")
def generate_sitemap_xml(canonical_url, allowed_paths, output_dir):
"""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 = ""

View File

@ -0,0 +1,123 @@
import logging
from pathlib import Path
from PIL import Image, features
from shutil import copyfile
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
"""Convert an image to WebP (or JPEG fallback) and optionally resize it."""
try:
if not input_path.exists():
logging.error(f"[✗] Image file not found: {input_path}")
return
img = Image.open(input_path)
if img.mode != "RGB":
img = img.convert("RGB")
if resize:
width, height = img.size
if width > max_width:
new_height = int((max_width / width) * height)
img = img.resize((max_width, new_height), Image.LANCZOS)
output_path.parent.mkdir(parents=True, exist_ok=True)
# Check WebP support, otherwise fallback to JPEG
fmt = "WEBP" if features.check("webp") else "JPEG"
if fmt == "JPEG":
output_path = output_path.with_suffix(".jpg")
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
logging.info(f"[✓] Processed image: {input_path}{output_path}")
except Exception as e:
logging.error(f"[✗] Error processing image {input_path}: {e}")
def process_images(images, resize_images, img_dir, build_dir):
"""Process a list of image references and update paths to optimized versions."""
for img in images:
src_path = img_dir / img["src"]
webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
convert_and_resize_image(src_path, webp_path, resize=resize_images)
if webp_path.exists():
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
else:
# Fallback if WebP not created
jpg_path = webp_path.with_suffix(".jpg")
if jpg_path.exists():
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
def copy_original_images(images, img_dir, build_dir):
"""Copy original image files without processing."""
for img in images:
src_path = img_dir / img["src"]
dest_path = build_dir / "img" / img["src"]
try:
if not src_path.exists():
logging.error(f"[✗] Original image not found: {src_path}")
continue
dest_path.parent.mkdir(parents=True, exist_ok=True)
copyfile(src_path, dest_path)
logging.info(f"[✓] Copied original: {src_path}{dest_path}")
except Exception as e:
logging.error(f"[✗] Error copying {src_path}: {e}")
def get_favicon_path(theme_vars, theme_dir):
"""Retrieve the favicon path from theme variables, ensuring it exists."""
fav_path = theme_vars.get("favicon", {}).get("path")
if not fav_path:
logging.warning("[~] No favicon path defined in theme.yaml")
return None
path = Path(fav_path)
if not path.is_absolute():
path = theme_dir / path
if not path.exists():
logging.error(f"[✗] Favicon not found: {path}")
return None
return path
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
"""Generate multiple PNG favicons from a single source image."""
logo_path = get_favicon_path(theme_vars, theme_dir)
if not logo_path:
logging.warning("[~] PNG favicons not generated.")
return
try:
output_dir.mkdir(parents=True, exist_ok=True)
specs = [
(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
(192, "favicon-192.png"), (196, "favicon-196.png"),
(152, "favicon-152.png"), (180, "favicon-180.png")
]
img = Image.open(logo_path).convert("RGBA")
for size, name in specs:
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
logging.info(f"[✓] PNG favicons generated in {output_dir}")
except Exception as e:
logging.error(f"[✗] Error generating PNG favicons: {e}")
def generate_favicon_ico(theme_vars, theme_dir, output_path):
"""Generate a multi-size favicon.ico from a source image."""
logo_path = get_favicon_path(theme_vars, theme_dir)
if not logo_path:
logging.warning("[~] favicon.ico not generated.")
return
try:
img = Image.open(logo_path).convert("RGBA")
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
logging.info(f"[✓] favicon.ico generated in {output_path}")
except Exception as e:
logging.error(f"[✗] Error generating favicon.ico: {e}")

View File

@ -0,0 +1,189 @@
import logging
from datetime import datetime
from pathlib import Path
from shutil import copyfile
from PIL import Image
from .utils import ensure_dir, copy_assets, load_yaml, load_theme_config
from .css_generator import generate_css_variables, generate_fonts_css, generate_google_fonts_link
from .image_processor import process_images, copy_original_images, convert_and_resize_image, generate_favicons_from_logo, generate_favicon_ico
from .html_generator import render_template, render_gallery_images, generate_gallery_json_from_images, generate_robots_txt, generate_sitemap_xml
# Configure logging to display only the messages
logging.basicConfig(level=logging.INFO, format='%(message)s')
# Define key directories used throughout the script
SRC_DIR = Path.cwd()
BUILD_DIR = SRC_DIR / "output"
TEMPLATE_DIR = SRC_DIR / "src/templates"
IMG_DIR = SRC_DIR / "config/photos"
JS_DIR = SRC_DIR / "src/public/js"
STYLE_DIR = SRC_DIR / "src/public/style"
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
SITE_FILE = SRC_DIR / "config/site.yaml"
THEMES_DIR = SRC_DIR / "config/themes"
def build():
build_version = "v1.3.1"
logging.info("\n")
logging.info("=" * 24)
logging.info(f"🚀 Lumeex builder {build_version}")
logging.info("=" * 24)
logging.info("\n === Starting build === ")
ensure_dir(BUILD_DIR)
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
# Defining build vars
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
site_vars = load_yaml(SITE_FILE)
gallery_vars = load_yaml(GALLERY_FILE)
build_section = site_vars.get("build", {})
theme_name = site_vars.get("build", {}).get("theme", "default")
theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
fonts_dir = theme_dir / "fonts"
theme_css_path = theme_dir / "theme.css"
canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
canonical_home = f"{canonical_url}/"
canonical_legals = f"{canonical_url}/legals/"
# Copying theme.css if existing
if theme_css_path.exists():
dest_theme_css = BUILD_DIR / "style" / "theme.css"
dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
copyfile(theme_css_path, dest_theme_css)
theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
else:
theme_css = ""
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
# Converting and resizing images if enabled
convert_images = build_section.get("convert_images", True)
resize_images = build_section.get("resize_images", True)
logging.info(f"[~] convert_images = {convert_images}")
logging.info(f"[~] resize_images = {resize_images}")
hero_images = gallery_vars.get("hero", {}).get("images", [])
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
if convert_images:
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
else:
copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
if "hero" not in site_vars:
site_vars["hero"] = {} # Initialize an empty hero section
# Adding menu
menu_html = "\n".join(
f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
for item in site_vars.get("menu", {}).get("items", [])
)
site_vars["hero"]["menu_items"] = menu_html
if "footer" in site_vars:
site_vars["footer"]["menu_items"] = menu_html
# Adding Google fonts if existing
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
logging.info(f"[✓] Google Fonts link generated")
# Generating thumbnail
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
if thumbnail_path:
src_thumb = IMG_DIR / thumbnail_path
dest_thumb_dir = BUILD_DIR / "img" / "social"
dest_thumb_dir.mkdir(parents=True, exist_ok=True)
dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
try:
img = Image.open(src_thumb)
img = img.convert("RGB")
img = img.resize((1200, 630), Image.LANCZOS)
img.save(dest_thumb, "JPEG", quality=90)
logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
except Exception as e:
logging.error(f"[✗] Failed to process thumbnail: {e}")
else:
logging.warning("[~] No thumbnail found in social section")
# Defining head variables
head_vars = dict(site_vars.get("info", {}))
head_vars.update(theme_vars.get("colors", {}))
head_vars.update(site_vars.get("social", {}))
head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
head_vars["google_fonts_link"] = google_fonts_link
head_vars["font_preloads"] = "\n".join(preload_links)
head_vars["theme_css"] = theme_css
head_vars["build_date"] = build_date
head_vars["canonical"] = canonical_home
# Render the home page
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
gallery_html = render_gallery_images(gallery_images)
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
signature = f"<!-- Build with Lumeex {build_version} | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
body = f"""
<body>
<div class="page-loader"><div class="spinner"></div></div>
{hero}
{gallery}
{footer}
</body>
"""
output_file = BUILD_DIR / "index.html"
with open(output_file, "w", encoding="utf-8") as f:
f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
logging.info(f"[✓] HTML generated: {output_file}")
# Rendering legals page
head_vars["canonical"] = canonical_legals
legals_vars = site_vars.get("legals", {})
if legals_vars:
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
ip_paragraphs = legals_vars.get("intellectual_property", [])
paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
legals_context = {
"hoster_name": legals_vars.get("hoster_name", ""),
"hoster_adress": legals_vars.get("hoster_adress", ""),
"hoster_contact": legals_vars.get("hoster_contact", ""),
"intellectual_property": paragraphs_html,
}
legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
output_legals = BUILD_DIR / "legals" / "index.html"
output_legals.parent.mkdir(parents=True, exist_ok=True)
with open(output_legals, "w", encoding="utf-8") as f:
f.write(legals_html)
logging.info(f"[✓] Legals page generated: {output_legals}")
else:
logging.warning("[~] No legals section found in site.yaml")
# Hero carrousel generator
if hero_images:
generate_gallery_json_from_images(hero_images, BUILD_DIR)
else:
logging.warning("[~] No hero images found, skipping JSON generation.")
# Sitemap and robot.txt generator
site_info = site_vars.get("info", {})
canonical_url = site_info.get("canonical", "").rstrip("/")
if canonical_url:
allowed_pages = ["/", "/legals/"]
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
else:
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
logging.info("✅ Build complete.")

View File

@ -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():
@ -19,12 +21,26 @@ def load_theme_config(theme_name, themes_dir):
theme_vars = yaml.safe_load(f)
return theme_vars, theme_dir
def ensure_dir(path):
if path.exists():
rmtree(path)
path.mkdir(parents=True)
def clear_dir(path: Path):
"""Clear the output dir"""
if not path.exists():
path.mkdir(parents=True)
return
for child in path.iterdir():
if child.is_file() or child.is_symlink():
child.unlink()
elif child.is_dir():
rmtree(child)
def ensure_dir(path: Path):
"""Create the output dir if it does not exist"""
if not path.exists():
path.mkdir(parents=True)
else:
clear_dir(path)
def copy_assets(js_dir, style_dir, build_dir):
"""Copy public assets to output dir"""
for folder in [js_dir, style_dir]:
if folder.exists():
dest = build_dir / folder.name

View File

@ -1,73 +0,0 @@
import logging
from pathlib import Path
from PIL import Image
from shutil import copyfile
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
try:
img = Image.open(input_path)
if img.mode != "RGB":
img = img.convert("RGB")
if resize:
width, height = img.size
if width > max_width:
new_height = int((max_width / width) * height)
img = img.resize((max_width, new_height), Image.LANCZOS)
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(output_path, "WEBP", quality=100)
logging.info(f"[✓] Processed: {input_path}{output_path}")
except Exception as e:
logging.error(f"[✗] Failed to process {input_path}: {e}")
def process_images(images, resize_images, img_dir, build_dir):
for img in images:
src_path = img_dir / img["src"]
webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
convert_and_resize_image(src_path, webp_path, resize=resize_images)
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
def copy_original_images(images, img_dir, build_dir):
for img in images:
src_path = img_dir / img["src"]
dest_path = build_dir / "img" / img["src"]
try:
dest_path.parent.mkdir(parents=True, exist_ok=True)
copyfile(src_path, dest_path)
logging.info(f"[✓] Copied original: {src_path}{dest_path}")
except Exception as e:
logging.error(f"[✗] Failed to copy {src_path}: {e}")
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
logo_path = get_favicon_path(theme_vars, theme_dir)
if not logo_path:
logging.warning("[~] No favicon path defined, skipping favicon PNGs.")
return
output_dir.mkdir(parents=True, exist_ok=True)
specs = [(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
(192, "favicon-192.png"), (196, "favicon-196.png"), (152, "favicon-152.png"), (180, "favicon-180.png")]
img = Image.open(logo_path).convert("RGBA")
for size, name in specs:
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
logging.info(f"[✓] Favicons generated in {output_dir}")
def generate_favicon_ico(theme_vars, theme_dir, output_path):
logo_path = get_favicon_path(theme_vars, theme_dir)
if not logo_path:
logging.warning("[~] No favicon path defined, skipping .ico generation.")
return
try:
img = Image.open(logo_path).convert("RGBA")
output_path.parent.mkdir(parents=True, exist_ok=True)
img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
logging.info(f"[✓] favicon.ico generated at {output_path}")
except Exception as e:
logging.error(f"[✗] Failed to generate favicon.ico: {e}")
def get_favicon_path(theme_vars, theme_dir):
fav_path = theme_vars.get("favicon", {}).get("path")
if not fav_path:
return None
path = Path(fav_path)
if not path.is_absolute():
path = theme_dir / path
return path if path.exists() else None

0
src/py/webui/__init__.py Normal file
View File

66
src/py/webui/upload.py Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

166
src/webui/img/logo.svg Normal file
View 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
View 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">&times;</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
View 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
View 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");
}
});
}
});

View 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
View 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 = ''; }
});

View 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">&times;</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
View 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;
}

View 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">&times;</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">&times;</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>