Compare commits
40 Commits
v1.2
...
b2ba1d7c7f
Author | SHA1 | Date | |
---|---|---|---|
b2ba1d7c7f | |||
5d238fcf33 | |||
7675b90909 | |||
a916c80c2a | |||
cb91b92555 | |||
f6e6a11fc1 | |||
e9a3a5a189 | |||
4ac176f8a9 | |||
1ea94b469b | |||
2ec4be624b | |||
369704a87c | |||
330e467dcb | |||
305042b365 | |||
7a7876e5ef | |||
8cb81a74cf | |||
c193fd49aa | |||
5d863223e3 | |||
a8f3c1b497 | |||
b74f1bb350 | |||
5a6f08644a | |||
031ff62168 | |||
b56d03303e | |||
d3484a4b50 | |||
9d37b0a60f | |||
080eb2593d | |||
73a0dd0ce6 | |||
97645b06fa | |||
142c042b86 | |||
041db66b3d | |||
1b0b228273 | |||
41450837f2 | |||
4edeb8709a | |||
6fc573c510 | |||
43c007c1fe | |||
dfbd532efd | |||
efe1bbca29 | |||
7e1a5e659f | |||
f5a5aefd09 | |||
f76420b2c3 | |||
3901bf8acf |
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,4 +1,5 @@
|
||||
.*
|
||||
!.sh
|
||||
!.gitignore
|
||||
output/
|
||||
__pycache__/
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY build.py gallery.py VERSION /app/
|
||||
COPY ./src/ ./src/
|
||||
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"]
|
29
README.MD
29
README.MD
@ -24,8 +24,7 @@ The project includes two thoughtfully designed themes—one modern, one minimali
|
||||
## 📌 Table of Contents
|
||||
|
||||
- [✨ Features](#-features)
|
||||
- [🐍 Python Installation](#-python-installation)
|
||||
- [⚙️ Configuration](#-configuration)
|
||||
- [🐳 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
|
185
build.py
185
build.py
@ -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__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
build()
|
||||
|
@ -1,6 +1,3 @@
|
||||
# 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:
|
||||
|
@ -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:
|
||||
|
@ -8,8 +8,8 @@ colors:
|
||||
secondary: '#00b0f0'
|
||||
accent: '#ffc700'
|
||||
text_dark: '#616161'
|
||||
background: '#fff'
|
||||
browser_color: '#fff'
|
||||
background: '#ffffff'
|
||||
browser_color: '#ffffff'
|
||||
favicon:
|
||||
path: favicon.png
|
||||
google_fonts:
|
||||
|
@ -8,8 +8,8 @@ colors:
|
||||
secondary: '#00b0f0'
|
||||
accent: '#ffc700'
|
||||
text_dark: '#616161'
|
||||
background: '#fff'
|
||||
browser_color: '#fff'
|
||||
background: '#ffffff'
|
||||
browser_color: '#ffffff'
|
||||
favicon:
|
||||
path: favicon.png
|
||||
fonts:
|
||||
|
@ -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
|
||||
|
86
docker/.sh/entrypoint.sh
Normal file
86
docker/.sh/entrypoint.sh
Normal file
@ -0,0 +1,86 @@
|
||||
#!/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 preview HTTP server on port 3000..."
|
||||
python3 -u -m http.server 3000 -d /app/output &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting Lumeex Flask webui..."
|
||||
python3 -u -m src.py.webui.webui &
|
||||
WEBUI_PID=$!
|
||||
|
||||
trap "echo 'Stopping servers...'; kill -TERM $SERVER_PID $WEBUI_PID 2>/dev/null; wait $SERVER_PID $WEBUI_PID; exit 0" SIGINT SIGTERM
|
||||
|
||||
wait $SERVER_PID
|
||||
wait $WEBUI_PID
|
||||
}
|
||||
|
||||
VERSION=$(cat VERSION)
|
||||
if [ $# -eq 0 ]; then
|
||||
echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}"
|
||||
echo -e "${CYAN}│${NC} Lum${CYAN}eex${NC} - Version ${VERSION}${NC} ${CYAN}│${NC}"
|
||||
echo -e "${CYAN}├───────────────────────────────────────────┤${NC}"
|
||||
echo -e "${CYAN}│${NC} Source: https://git.djeex.fr/Djeex/lumeex ${CYAN}│${NC}"
|
||||
echo -e "${CYAN}│${NC} Mirror: https://github.com/Djeex/lumeex ${CYAN}│${NC}"
|
||||
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
|
11
docker/docker-compose.yaml
Normal file
11
docker/docker-compose.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
services:
|
||||
lumeex:
|
||||
container_name: lmx
|
||||
build: ..
|
||||
volumes:
|
||||
- ../config:/app/config # mount config directory
|
||||
- ../output:/app/output # mount output directory
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "5000:5000"
|
||||
|
112
gallery.py
112
gallery.py
@ -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()
|
@ -1,2 +1,3 @@
|
||||
pyyaml
|
||||
pillow
|
||||
flask
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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 = []
|
114
src/py/builder/gallery_builder.py
Normal file
114
src/py/builder/gallery_builder.py
Normal file
@ -0,0 +1,114 @@
|
||||
import yaml
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# YAML file paths
|
||||
GALLERY_YAML = "config/gallery.yaml"
|
||||
|
||||
# Image directories
|
||||
GALLERY_DIR = Path("config/photos/gallery")
|
||||
HERO_DIR = Path("config/photos/hero")
|
||||
|
||||
def load_yaml(path):
|
||||
"""Load gallery config .yaml file"""
|
||||
print(f"[→] Loading {path}...")
|
||||
if not os.path.exists(path):
|
||||
print(f"[✗] File not found: {path}")
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
images = data.get("images", []) or []
|
||||
print(f"[✓] Loaded {len(images)} image(s) from {path}")
|
||||
return data
|
||||
|
||||
def save_yaml(data, path):
|
||||
"""Save modified gallery config .yaml file"""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
print(f"[✓] Saved updated YAML to {path}")
|
||||
|
||||
def get_all_image_paths(directory):
|
||||
"""Get the path to record for builded site"""
|
||||
return sorted([
|
||||
str(p.relative_to(directory.parent)).replace("\\", "/")
|
||||
for p in directory.rglob("*")
|
||||
if p.suffix.lower() in [".jpg", ".jpeg", ".png", ".webp"]
|
||||
])
|
||||
|
||||
def update_gallery():
|
||||
"""Update the gallery photo list"""
|
||||
print("\n=== Updating gallery.yaml (gallery section) ===")
|
||||
gallery = load_yaml(GALLERY_YAML)
|
||||
|
||||
# Access the 'gallery' section within the gallery data, or initialize it if it doesn't exist
|
||||
gallery_section = gallery.get("gallery", {})
|
||||
|
||||
# Access the 'images' list within the 'gallery' section, or initialize it if it doesn't exist
|
||||
gallery_images = gallery_section.get("images", [])
|
||||
|
||||
all_images = set(get_all_image_paths(GALLERY_DIR))
|
||||
known_images = {img["src"] for img in gallery_images}
|
||||
|
||||
# Add new images
|
||||
new_images = [
|
||||
{"src": path, "tags": []}
|
||||
for path in all_images
|
||||
if path not in known_images
|
||||
]
|
||||
if new_images:
|
||||
gallery_images.extend(new_images)
|
||||
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (gallery)")
|
||||
|
||||
# Remove deleted images
|
||||
deleted_images = known_images - all_images
|
||||
if deleted_images:
|
||||
gallery_images = [img for img in gallery_images if img["src"] not in deleted_images]
|
||||
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (gallery)")
|
||||
|
||||
# Update the 'gallery' section with the modified 'images' list
|
||||
gallery_section["images"] = gallery_images
|
||||
gallery["gallery"] = gallery_section
|
||||
|
||||
save_yaml(gallery, GALLERY_YAML)
|
||||
|
||||
if not new_images and not deleted_images:
|
||||
print("[✓] No changes to gallery.yaml (gallery)")
|
||||
|
||||
def update_hero():
|
||||
"""Update the hero photo list"""
|
||||
print("\n=== Updating gallery.yaml (hero section) ===")
|
||||
gallery = load_yaml(GALLERY_YAML)
|
||||
|
||||
# Access the 'hero' section within the gallery data, or initialize it if it doesn't exist
|
||||
hero_section = gallery.get("hero", {})
|
||||
|
||||
# Access the 'images' list within the 'hero' section, or initialize it if it doesn't exist
|
||||
hero_images = hero_section.get("images", [])
|
||||
|
||||
all_images = set(get_all_image_paths(HERO_DIR))
|
||||
known_images = {img["src"] for img in hero_images}
|
||||
|
||||
# Add new images
|
||||
new_images = [
|
||||
{"src": path}
|
||||
for path in all_images
|
||||
if path not in known_images
|
||||
]
|
||||
if new_images:
|
||||
hero_images.extend(new_images)
|
||||
print(f"[✓] Added {len(new_images)} new image(s) to gallery.yaml (hero)")
|
||||
|
||||
# Remove deleted images
|
||||
deleted_images = known_images - all_images
|
||||
if deleted_images:
|
||||
hero_images = [img for img in hero_images if img["src"] not in deleted_images]
|
||||
print(f"[✓] Removed {len(deleted_images)} deleted image(s) from gallery.yaml (hero)")
|
||||
|
||||
# Update the 'hero' section with the modified 'images' list
|
||||
hero_section["images"] = hero_images
|
||||
gallery["hero"] = hero_section
|
||||
|
||||
save_yaml(gallery, GALLERY_YAML)
|
||||
|
||||
if not new_images and not deleted_images:
|
||||
print("[✓] No changes to gallery.yaml (hero)")
|
@ -3,6 +3,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
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"
|
||||
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 = ""
|
123
src/py/builder/image_processor.py
Normal file
123
src/py/builder/image_processor.py
Normal file
@ -0,0 +1,123 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PIL import Image, features
|
||||
from shutil import copyfile
|
||||
|
||||
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
|
||||
"""Convert an image to WebP (or JPEG fallback) and optionally resize it."""
|
||||
try:
|
||||
if not input_path.exists():
|
||||
logging.error(f"[✗] Image file not found: {input_path}")
|
||||
return
|
||||
|
||||
img = Image.open(input_path)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
if resize:
|
||||
width, height = img.size
|
||||
if width > max_width:
|
||||
new_height = int((max_width / width) * height)
|
||||
img = img.resize((max_width, new_height), Image.LANCZOS)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Check WebP support, otherwise fallback to JPEG
|
||||
fmt = "WEBP" if features.check("webp") else "JPEG"
|
||||
if fmt == "JPEG":
|
||||
output_path = output_path.with_suffix(".jpg")
|
||||
|
||||
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
|
||||
logging.info(f"[✓] Processed image: {input_path} → {output_path}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error processing image {input_path}: {e}")
|
||||
|
||||
def process_images(images, resize_images, img_dir, build_dir):
|
||||
"""Process a list of image references and update paths to optimized versions."""
|
||||
for img in images:
|
||||
src_path = img_dir / img["src"]
|
||||
webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
|
||||
convert_and_resize_image(src_path, webp_path, resize=resize_images)
|
||||
|
||||
if webp_path.exists():
|
||||
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
|
||||
else:
|
||||
# Fallback if WebP not created
|
||||
jpg_path = webp_path.with_suffix(".jpg")
|
||||
if jpg_path.exists():
|
||||
img["src"] = str(Path(img["src"]).with_suffix(".jpg"))
|
||||
|
||||
def copy_original_images(images, img_dir, build_dir):
|
||||
"""Copy original image files without processing."""
|
||||
for img in images:
|
||||
src_path = img_dir / img["src"]
|
||||
dest_path = build_dir / "img" / img["src"]
|
||||
|
||||
try:
|
||||
if not src_path.exists():
|
||||
logging.error(f"[✗] Original image not found: {src_path}")
|
||||
continue
|
||||
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
copyfile(src_path, dest_path)
|
||||
logging.info(f"[✓] Copied original: {src_path} → {dest_path}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error copying {src_path}: {e}")
|
||||
|
||||
def get_favicon_path(theme_vars, theme_dir):
|
||||
"""Retrieve the favicon path from theme variables, ensuring it exists."""
|
||||
fav_path = theme_vars.get("favicon", {}).get("path")
|
||||
if not fav_path:
|
||||
logging.warning("[~] No favicon path defined in theme.yaml")
|
||||
return None
|
||||
|
||||
path = Path(fav_path)
|
||||
if not path.is_absolute():
|
||||
path = theme_dir / path
|
||||
|
||||
if not path.exists():
|
||||
logging.error(f"[✗] Favicon not found: {path}")
|
||||
return None
|
||||
|
||||
return path
|
||||
|
||||
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
||||
"""Generate multiple PNG favicons from a single source image."""
|
||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||
if not logo_path:
|
||||
logging.warning("[~] PNG favicons not generated.")
|
||||
return
|
||||
|
||||
try:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
specs = [
|
||||
(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
|
||||
(192, "favicon-192.png"), (196, "favicon-196.png"),
|
||||
(152, "favicon-152.png"), (180, "favicon-180.png")
|
||||
]
|
||||
img = Image.open(logo_path).convert("RGBA")
|
||||
for size, name in specs:
|
||||
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
|
||||
|
||||
logging.info(f"[✓] PNG favicons generated in {output_dir}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error generating PNG favicons: {e}")
|
||||
|
||||
def generate_favicon_ico(theme_vars, theme_dir, output_path):
|
||||
"""Generate a multi-size favicon.ico from a source image."""
|
||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||
if not logo_path:
|
||||
logging.warning("[~] favicon.ico not generated.")
|
||||
return
|
||||
|
||||
try:
|
||||
img = Image.open(logo_path).convert("RGBA")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
|
||||
logging.info(f"[✓] favicon.ico generated in {output_path}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Error generating favicon.ico: {e}")
|
191
src/py/builder/site_builder.py
Normal file
191
src/py/builder/site_builder.py
Normal file
@ -0,0 +1,191 @@
|
||||
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"
|
||||
VERSION_FILE = SRC_DIR / "VERSION"
|
||||
with open(VERSION_FILE, "r") as vf:
|
||||
build_version = vf.read().strip()
|
||||
|
||||
def build():
|
||||
logging.info("\n")
|
||||
logging.info("=" * 24)
|
||||
logging.info(f"🚀 Lumeex builder v{build_version}")
|
||||
logging.info("=" * 24)
|
||||
logging.info("\n === Starting build === ")
|
||||
ensure_dir(BUILD_DIR)
|
||||
copy_assets(JS_DIR, STYLE_DIR, BUILD_DIR)
|
||||
|
||||
# Defining build vars
|
||||
build_date = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
build_date_version = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
site_vars = load_yaml(SITE_FILE)
|
||||
gallery_vars = load_yaml(GALLERY_FILE)
|
||||
build_section = site_vars.get("build", {})
|
||||
theme_name = site_vars.get("build", {}).get("theme", "default")
|
||||
theme_vars, theme_dir = load_theme_config(theme_name, THEMES_DIR)
|
||||
fonts_dir = theme_dir / "fonts"
|
||||
theme_css_path = theme_dir / "theme.css"
|
||||
canonical_url = site_vars.get("info", {}).get("canonical", "").rstrip("/")
|
||||
canonical_home = f"{canonical_url}/"
|
||||
canonical_legals = f"{canonical_url}/legals/"
|
||||
|
||||
# Copying theme.css if existing
|
||||
if theme_css_path.exists():
|
||||
dest_theme_css = BUILD_DIR / "style" / "theme.css"
|
||||
dest_theme_css.parent.mkdir(parents=True, exist_ok=True)
|
||||
copyfile(theme_css_path, dest_theme_css)
|
||||
theme_css = f'<link rel="stylesheet" href="/style/theme.css?build_date={build_date}">'
|
||||
logging.info(f"[✓] Theme CSS found, copied to build folder: {dest_theme_css}")
|
||||
else:
|
||||
theme_css = ""
|
||||
logging.warning(f"[~] No theme.css found in {theme_css_path}, skipping theme CSS injection.")
|
||||
|
||||
preload_links = generate_fonts_css(fonts_dir, BUILD_DIR / "style" / "fonts.css", fonts_cfg=theme_vars.get("fonts"))
|
||||
generate_css_variables(theme_vars.get("colors", {}), BUILD_DIR / "style" / "colors.css")
|
||||
generate_favicons_from_logo(theme_vars, theme_dir, BUILD_DIR / "img" / "favicon")
|
||||
generate_favicon_ico(theme_vars, theme_dir, BUILD_DIR / "favicon.ico")
|
||||
|
||||
# Converting and resizing images if enabled
|
||||
convert_images = build_section.get("convert_images", True)
|
||||
resize_images = build_section.get("resize_images", True)
|
||||
logging.info(f"[~] convert_images = {convert_images}")
|
||||
logging.info(f"[~] resize_images = {resize_images}")
|
||||
|
||||
hero_images = gallery_vars.get("hero", {}).get("images", [])
|
||||
gallery_images = gallery_vars.get("gallery", {}).get("images", [])
|
||||
|
||||
if convert_images:
|
||||
process_images(hero_images, resize_images, IMG_DIR, BUILD_DIR)
|
||||
process_images(gallery_images, resize_images, IMG_DIR, BUILD_DIR)
|
||||
else:
|
||||
copy_original_images(hero_images, IMG_DIR, BUILD_DIR)
|
||||
copy_original_images(gallery_images, IMG_DIR, BUILD_DIR)
|
||||
|
||||
if "hero" not in site_vars:
|
||||
site_vars["hero"] = {} # Initialize an empty hero section
|
||||
|
||||
# Adding menu
|
||||
menu_html = "\n".join(
|
||||
f'<li class="nav-item appear"><a href="{item["href"]}">{item["label"]}</a></li>'
|
||||
for item in site_vars.get("menu", {}).get("items", [])
|
||||
)
|
||||
site_vars["hero"]["menu_items"] = menu_html
|
||||
if "footer" in site_vars:
|
||||
site_vars["footer"]["menu_items"] = menu_html
|
||||
|
||||
# Adding Google fonts if existing
|
||||
google_fonts_link = generate_google_fonts_link(theme_vars.get("google_fonts", []))
|
||||
logging.info(f"[✓] Google Fonts link generated")
|
||||
|
||||
# Generating thumbnail
|
||||
thumbnail_path = site_vars.get("social", {}).get("thumbnail")
|
||||
if thumbnail_path:
|
||||
src_thumb = IMG_DIR / thumbnail_path
|
||||
dest_thumb_dir = BUILD_DIR / "img" / "social"
|
||||
dest_thumb_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_thumb = dest_thumb_dir / Path(thumbnail_path).name
|
||||
try:
|
||||
img = Image.open(src_thumb)
|
||||
img = img.convert("RGB")
|
||||
img = img.resize((1200, 630), Image.LANCZOS)
|
||||
img.save(dest_thumb, "JPEG", quality=90)
|
||||
logging.info(f"[✓] Thumbnail resized and saved to {dest_thumb}")
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Failed to process thumbnail: {e}")
|
||||
else:
|
||||
logging.warning("[~] No thumbnail found in social section")
|
||||
|
||||
# Defining head variables
|
||||
head_vars = dict(site_vars.get("info", {}))
|
||||
head_vars.update(theme_vars.get("colors", {}))
|
||||
head_vars.update(site_vars.get("social", {}))
|
||||
head_vars["thumbnail"] = f"/img/social/{Path(thumbnail_path).name}" if thumbnail_path else ""
|
||||
head_vars["google_fonts_link"] = google_fonts_link
|
||||
head_vars["font_preloads"] = "\n".join(preload_links)
|
||||
head_vars["theme_css"] = theme_css
|
||||
head_vars["build_date"] = build_date
|
||||
head_vars["canonical"] = canonical_home
|
||||
|
||||
# Render the home page
|
||||
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
||||
hero = render_template(TEMPLATE_DIR / "hero.html", {**site_vars["hero"], **head_vars})
|
||||
footer = render_template(TEMPLATE_DIR / "footer.html", {**site_vars.get("footer", {}), **head_vars})
|
||||
gallery_html = render_gallery_images(gallery_images)
|
||||
gallery = render_template(TEMPLATE_DIR / "gallery.html", {"gallery_images": gallery_html})
|
||||
|
||||
signature = f"<!-- Build with Lumeex {build_version} | https://git.djeex.fr/Djeex/lumeex | {build_date_version} -->"
|
||||
body = f"""
|
||||
<body>
|
||||
<div class="page-loader"><div class="spinner"></div></div>
|
||||
{hero}
|
||||
{gallery}
|
||||
{footer}
|
||||
</body>
|
||||
"""
|
||||
output_file = BUILD_DIR / "index.html"
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
f.write(f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{body}\n</html>")
|
||||
logging.info(f"[✓] HTML generated: {output_file}")
|
||||
|
||||
# Rendering legals page
|
||||
head_vars["canonical"] = canonical_legals
|
||||
|
||||
legals_vars = site_vars.get("legals", {})
|
||||
if legals_vars:
|
||||
head = render_template(TEMPLATE_DIR / "head.html", head_vars)
|
||||
|
||||
ip_paragraphs = legals_vars.get("intellectual_property", [])
|
||||
paragraphs_html = "\n".join(f"<p>{item['paragraph']}</p>" for item in ip_paragraphs)
|
||||
legals_context = {
|
||||
"hoster_name": legals_vars.get("hoster_name", ""),
|
||||
"hoster_adress": legals_vars.get("hoster_adress", ""),
|
||||
"hoster_contact": legals_vars.get("hoster_contact", ""),
|
||||
"intellectual_property": paragraphs_html,
|
||||
}
|
||||
legals_body = render_template(TEMPLATE_DIR / "legals.html", legals_context)
|
||||
legals_html = f"<!DOCTYPE html>\n{signature}\n<html lang='en'>\n{head}\n{legals_body}\n{footer}\n</html>"
|
||||
output_legals = BUILD_DIR / "legals" / "index.html"
|
||||
output_legals.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_legals, "w", encoding="utf-8") as f:
|
||||
f.write(legals_html)
|
||||
logging.info(f"[✓] Legals page generated: {output_legals}")
|
||||
else:
|
||||
logging.warning("[~] No legals section found in site.yaml")
|
||||
|
||||
# Hero carrousel generator
|
||||
if hero_images:
|
||||
generate_gallery_json_from_images(hero_images, BUILD_DIR)
|
||||
else:
|
||||
logging.warning("[~] No hero images found, skipping JSON generation.")
|
||||
|
||||
# Sitemap and robot.txt generator
|
||||
site_info = site_vars.get("info", {})
|
||||
canonical_url = site_info.get("canonical", "").rstrip("/")
|
||||
if canonical_url:
|
||||
allowed_pages = ["/", "/legals/"]
|
||||
generate_robots_txt(canonical_url, allowed_pages, BUILD_DIR)
|
||||
generate_sitemap_xml(canonical_url, allowed_pages, BUILD_DIR)
|
||||
else:
|
||||
logging.warning("[~] No canonical URL found in site.yaml info section, skipping robots.txt and sitemap.xml generation.")
|
||||
|
||||
logging.info("✅ Build complete.")
|
||||
|
@ -4,6 +4,7 @@ from pathlib import Path
|
||||
from shutil import copytree, rmtree, copyfile
|
||||
|
||||
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)
|
||||
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
|
@ -1,73 +0,0 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from shutil import copyfile
|
||||
|
||||
def convert_and_resize_image(input_path, output_path, resize=True, max_width=1140):
|
||||
try:
|
||||
img = Image.open(input_path)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
if resize:
|
||||
width, height = img.size
|
||||
if width > max_width:
|
||||
new_height = int((max_width / width) * height)
|
||||
img = img.resize((max_width, new_height), Image.LANCZOS)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(output_path, "WEBP", quality=100)
|
||||
logging.info(f"[✓] Processed: {input_path} → {output_path}")
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Failed to process {input_path}: {e}")
|
||||
|
||||
def process_images(images, resize_images, img_dir, build_dir):
|
||||
for img in images:
|
||||
src_path = img_dir / img["src"]
|
||||
webp_path = build_dir / "img" / Path(img["src"]).with_suffix(".webp")
|
||||
convert_and_resize_image(src_path, webp_path, resize=resize_images)
|
||||
img["src"] = str(Path(img["src"]).with_suffix(".webp"))
|
||||
|
||||
def copy_original_images(images, img_dir, build_dir):
|
||||
for img in images:
|
||||
src_path = img_dir / img["src"]
|
||||
dest_path = build_dir / "img" / img["src"]
|
||||
try:
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
copyfile(src_path, dest_path)
|
||||
logging.info(f"[✓] Copied original: {src_path} → {dest_path}")
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Failed to copy {src_path}: {e}")
|
||||
|
||||
def generate_favicons_from_logo(theme_vars, theme_dir, output_dir):
|
||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||
if not logo_path:
|
||||
logging.warning("[~] No favicon path defined, skipping favicon PNGs.")
|
||||
return
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
specs = [(32, "favicon-32.png"), (96, "favicon-96.png"), (128, "favicon-128.png"),
|
||||
(192, "favicon-192.png"), (196, "favicon-196.png"), (152, "favicon-152.png"), (180, "favicon-180.png")]
|
||||
img = Image.open(logo_path).convert("RGBA")
|
||||
for size, name in specs:
|
||||
img.resize((size, size), Image.LANCZOS).save(output_dir / name, format="PNG")
|
||||
logging.info(f"[✓] Favicons generated in {output_dir}")
|
||||
|
||||
def generate_favicon_ico(theme_vars, theme_dir, output_path):
|
||||
logo_path = get_favicon_path(theme_vars, theme_dir)
|
||||
if not logo_path:
|
||||
logging.warning("[~] No favicon path defined, skipping .ico generation.")
|
||||
return
|
||||
try:
|
||||
img = Image.open(logo_path).convert("RGBA")
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img.save(output_path, format="ICO", sizes=[(16, 16), (32, 32), (48, 48)])
|
||||
logging.info(f"[✓] favicon.ico generated at {output_path}")
|
||||
except Exception as e:
|
||||
logging.error(f"[✗] Failed to generate favicon.ico: {e}")
|
||||
|
||||
def get_favicon_path(theme_vars, theme_dir):
|
||||
fav_path = theme_vars.get("favicon", {}).get("path")
|
||||
if not fav_path:
|
||||
return None
|
||||
path = Path(fav_path)
|
||||
if not path.is_absolute():
|
||||
path = theme_dir / path
|
||||
return path if path.exists() else None
|
0
src/py/webui/__init__.py
Normal file
0
src/py/webui/__init__.py
Normal file
66
src/py/webui/upload.py
Normal file
66
src/py/webui/upload.py
Normal file
@ -0,0 +1,66 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from flask import Blueprint, request, current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
from src.py.builder.gallery_builder import update_gallery, update_hero
|
||||
|
||||
# --- Create Flask blueprint for upload routes ---
|
||||
upload_bp = Blueprint("upload", __name__)
|
||||
|
||||
# --- Allowed file types ---
|
||||
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "webp"}
|
||||
|
||||
def allowed_file(filename: str) -> bool:
|
||||
"""Check if the uploaded file has an allowed extension."""
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def save_uploaded_file(file, folder: Path):
|
||||
"""Save an uploaded file to the specified folder."""
|
||||
folder.mkdir(parents=True, exist_ok=True) # Create folder if not exists
|
||||
filename = secure_filename(file.filename) # Sanitize filename
|
||||
file.save(folder / filename) # Save to disk
|
||||
logging.info(f"[✓] Uploaded {filename} to {folder}")
|
||||
return filename
|
||||
|
||||
@upload_bp.route("/api/<section>/upload", methods=["POST"])
|
||||
def upload_photo(section: str):
|
||||
"""
|
||||
Handle file uploads for gallery or hero section.
|
||||
Accepts multiple files under 'files'.
|
||||
"""
|
||||
# Validate section
|
||||
if section not in ["gallery", "hero"]:
|
||||
return {"error": "Invalid section"}, 400
|
||||
|
||||
# Check if files are provided
|
||||
if "files" not in request.files:
|
||||
return {"error": "No files provided"}, 400
|
||||
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
return {"error": "No selected files"}, 400
|
||||
|
||||
# Get photos directory from app config
|
||||
PHOTOS_DIR = current_app.config.get("PHOTOS_DIR")
|
||||
if not PHOTOS_DIR:
|
||||
return {"error": "Server misconfiguration"}, 500
|
||||
|
||||
folder = PHOTOS_DIR / section # Target folder
|
||||
uploaded = []
|
||||
|
||||
# Save each valid file
|
||||
for file in files:
|
||||
if file and allowed_file(file.filename):
|
||||
filename = save_uploaded_file(file, folder)
|
||||
uploaded.append(filename)
|
||||
|
||||
# Update YAML if any files were uploaded
|
||||
if uploaded:
|
||||
if section == "gallery":
|
||||
update_gallery()
|
||||
else:
|
||||
update_hero()
|
||||
return {"status": "ok", "uploaded": uploaded}
|
||||
|
||||
return {"error": "No valid files uploaded"}, 400
|
||||
|
491
src/py/webui/webui.py
Normal file
491
src/py/webui/webui.py
Normal file
@ -0,0 +1,491 @@
|
||||
# --- Imports ---
|
||||
import logging
|
||||
import yaml
|
||||
import subprocess
|
||||
import zipfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
from flask import (
|
||||
Flask, jsonify, request, send_from_directory, render_template,
|
||||
send_file, after_this_request
|
||||
)
|
||||
from src.py.builder.gallery_builder import (
|
||||
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
|
||||
)
|
||||
from src.py.webui.upload import upload_bp
|
||||
|
||||
# --- Logging configuration ---
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
# --- Flask app setup ---
|
||||
VERSION_FILE = Path(__file__).resolve().parents[3] / "VERSION"
|
||||
with open(VERSION_FILE, "r") as vf:
|
||||
lumeex_version = vf.read().strip()
|
||||
|
||||
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
|
||||
app = Flask(
|
||||
__name__,
|
||||
template_folder=WEBUI_PATH,
|
||||
static_folder=WEBUI_PATH,
|
||||
static_url_path=""
|
||||
)
|
||||
|
||||
# --- Config paths ---
|
||||
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||
app.config["PHOTOS_DIR"] = PHOTOS_DIR
|
||||
|
||||
# --- Register upload blueprint ---
|
||||
app.register_blueprint(upload_bp)
|
||||
|
||||
# --- Theme editor helper functions ---
|
||||
def get_theme_name():
|
||||
"""Get current theme name from site.yaml."""
|
||||
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||
with open(site_yaml_path, "r") as f:
|
||||
site_yaml = yaml.safe_load(f)
|
||||
return site_yaml.get("build", {}).get("theme", "modern")
|
||||
|
||||
def get_theme_yaml(theme_name):
|
||||
"""Load theme.yaml for a given theme."""
|
||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
def save_theme_yaml(theme_name, theme_yaml):
|
||||
"""Save theme.yaml for a given theme."""
|
||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
with open(theme_yaml_path, "w") as f:
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
|
||||
def get_local_fonts(theme_name):
|
||||
"""List local font files for a theme."""
|
||||
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
||||
if not fonts_dir.exists():
|
||||
return []
|
||||
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
|
||||
|
||||
# --- ROUTES ---
|
||||
|
||||
# --- Main page ---
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
return dict(lumeex_version=lumeex_version)
|
||||
|
||||
# --- Gallery & Hero API ---
|
||||
@app.route("/gallery-editor")
|
||||
def gallery_editor():
|
||||
"""Render gallery editor page."""
|
||||
return render_template("gallery-editor/index.html")
|
||||
|
||||
@app.route("/api/gallery", methods=["GET"])
|
||||
def get_gallery():
|
||||
"""Get gallery images."""
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
return jsonify(data.get("gallery", {}).get("images", []))
|
||||
|
||||
@app.route("/api/hero", methods=["GET"])
|
||||
def get_hero():
|
||||
"""Get hero images."""
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
return jsonify(data.get("hero", {}).get("images", []))
|
||||
|
||||
@app.route("/api/gallery/update", methods=["POST"])
|
||||
def update_gallery_api():
|
||||
"""Update gallery images."""
|
||||
images = request.json
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
data["gallery"]["images"] = images
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/hero/update", methods=["POST"])
|
||||
def update_hero_api():
|
||||
"""Update hero images."""
|
||||
images = request.json
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
data["hero"]["images"] = images
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/gallery/refresh", methods=["POST"])
|
||||
def refresh_gallery():
|
||||
"""Refresh gallery images from disk."""
|
||||
update_gallery()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/hero/refresh", methods=["POST"])
|
||||
def refresh_hero():
|
||||
"""Refresh hero images from disk."""
|
||||
update_hero()
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# --- Gallery & Hero photo deletion ---
|
||||
@app.route("/api/gallery/delete", methods=["POST"])
|
||||
def delete_gallery_photo():
|
||||
"""Delete a gallery photo."""
|
||||
data = request.json
|
||||
src = data.get("src")
|
||||
file_path = PHOTOS_DIR / "gallery" / src
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return {"status": "ok"}
|
||||
return {"error": "❌ File not found"}, 404
|
||||
|
||||
@app.route("/api/hero/delete", methods=["POST"])
|
||||
def delete_hero_photo():
|
||||
"""Delete a hero photo."""
|
||||
data = request.json
|
||||
src = data.get("src")
|
||||
file_path = PHOTOS_DIR / "hero" / src
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return {"status": "ok"}
|
||||
return {"error": "❌ File not found"}, 404
|
||||
|
||||
@app.route("/api/gallery/delete_all", methods=["POST"])
|
||||
def delete_all_gallery_photos():
|
||||
"""Delete all gallery photos."""
|
||||
gallery_dir = PHOTOS_DIR / "gallery"
|
||||
deleted = 0
|
||||
for file in gallery_dir.glob("*"):
|
||||
if file.is_file():
|
||||
file.unlink()
|
||||
deleted += 1
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
data["gallery"]["images"] = []
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok", "deleted": deleted})
|
||||
|
||||
@app.route("/api/hero/delete_all", methods=["POST"])
|
||||
def delete_all_hero_photos():
|
||||
"""Delete all hero photos."""
|
||||
hero_dir = PHOTOS_DIR / "hero"
|
||||
deleted = 0
|
||||
for file in hero_dir.glob("*"):
|
||||
if file.is_file():
|
||||
file.unlink()
|
||||
deleted += 1
|
||||
data = load_yaml(GALLERY_YAML)
|
||||
data["hero"]["images"] = []
|
||||
save_yaml(data, GALLERY_YAML)
|
||||
return jsonify({"status": "ok", "deleted": deleted})
|
||||
|
||||
# --- Serve photos ---
|
||||
@app.route("/photos/<section>/<path:filename>")
|
||||
def photos(section, filename):
|
||||
"""Serve a photo from a section."""
|
||||
return send_from_directory(PHOTOS_DIR / section, filename)
|
||||
|
||||
@app.route("/photos/<path:filename>")
|
||||
def serve_photo(filename):
|
||||
"""Serve a photo from the photos directory."""
|
||||
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||
return send_from_directory(photos_dir, filename)
|
||||
|
||||
# --- Site info page & API ---
|
||||
@app.route("/site-info")
|
||||
def site_info():
|
||||
"""Render site info editor page."""
|
||||
return render_template("site-info/index.html")
|
||||
|
||||
@app.route("/api/site-info", methods=["GET"])
|
||||
def get_site_info():
|
||||
"""Get site info YAML as JSON."""
|
||||
with open(SITE_YAML, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return jsonify(data)
|
||||
|
||||
@app.route("/api/site-info", methods=["POST"])
|
||||
def update_site_info():
|
||||
"""Update site info YAML."""
|
||||
data = request.json
|
||||
with open(SITE_YAML, "w") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# --- Theme management ---
|
||||
@app.route("/api/themes")
|
||||
def list_themes():
|
||||
"""List available themes."""
|
||||
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
|
||||
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
|
||||
return jsonify(themes)
|
||||
|
||||
# --- Thumbnail upload/remove ---
|
||||
@app.route("/api/thumbnail/upload", methods=["POST"])
|
||||
def upload_thumbnail():
|
||||
"""Upload thumbnail image and update site.yaml."""
|
||||
PHOTOS_DIR = app.config["PHOTOS_DIR"]
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
return {"error": "❌ No file provided"}, 400
|
||||
filename = "thumbnail.png"
|
||||
file.save(PHOTOS_DIR / filename)
|
||||
with open(SITE_YAML, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
data.setdefault("social", {})["thumbnail"] = filename
|
||||
with open(SITE_YAML, "w") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok", "filename": filename})
|
||||
|
||||
@app.route("/api/thumbnail/remove", methods=["POST"])
|
||||
def remove_thumbnail():
|
||||
"""Remove thumbnail image and update site.yaml."""
|
||||
PHOTOS_DIR = app.config["PHOTOS_DIR"]
|
||||
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
|
||||
if thumbnail_path.exists():
|
||||
thumbnail_path.unlink()
|
||||
with open(SITE_YAML, "r") as f:
|
||||
data = yaml.safe_load(f)
|
||||
if "social" in data and "thumbnail" in data["social"]:
|
||||
data["social"]["thumbnail"] = ""
|
||||
with open(SITE_YAML, "w") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# --- Theme upload ---
|
||||
@app.route("/api/theme/upload", methods=["POST"])
|
||||
def upload_theme():
|
||||
"""Upload a custom theme folder."""
|
||||
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
|
||||
files = request.files.getlist("files")
|
||||
if not files:
|
||||
return jsonify({"error": "❌ No files provided"}), 400
|
||||
first_path = files[0].filename
|
||||
folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
|
||||
theme_folder = themes_dir / folder_name
|
||||
theme_folder.mkdir(parents=True, exist_ok=True)
|
||||
for file in files:
|
||||
rel_path = Path(file.filename)
|
||||
dest_path = theme_folder / rel_path.relative_to(folder_name)
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file.save(dest_path)
|
||||
return jsonify({"status": "ok", "theme": folder_name})
|
||||
|
||||
@app.route("/api/theme/remove", methods=["POST"])
|
||||
def remove_theme():
|
||||
"""Remove a custom theme folder."""
|
||||
data = request.get_json()
|
||||
theme_name = data.get("theme")
|
||||
if not theme_name:
|
||||
return jsonify({"error": "❌ Missing theme"}), 400
|
||||
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
|
||||
theme_folder = themes_dir / theme_name
|
||||
if not theme_folder.exists() or not theme_folder.is_dir():
|
||||
return jsonify({"error": "❌ Theme not found"}), 404
|
||||
# Prevent removing default themes
|
||||
if theme_name in ["modern", "classic"]:
|
||||
return jsonify({"error": "❌ Cannot remove default theme"}), 400
|
||||
# Remove folder and all contents
|
||||
import shutil
|
||||
shutil.rmtree(theme_folder)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# --- Theme editor page & API ---
|
||||
@app.route("/theme-editor")
|
||||
def theme_editor():
|
||||
"""Render theme editor page."""
|
||||
return render_template("theme-editor/index.html")
|
||||
|
||||
@app.route("/api/theme-info", methods=["GET", "POST"])
|
||||
def api_theme_info():
|
||||
"""Get or update theme.yaml for current theme."""
|
||||
theme_name = get_theme_name()
|
||||
if request.method == "GET":
|
||||
theme_yaml = get_theme_yaml(theme_name)
|
||||
google_fonts = theme_yaml.get("google_fonts", [])
|
||||
return jsonify({
|
||||
"theme_name": theme_name,
|
||||
"theme_yaml": theme_yaml,
|
||||
"google_fonts": google_fonts
|
||||
})
|
||||
else:
|
||||
data = request.get_json()
|
||||
theme_yaml = data.get("theme_yaml")
|
||||
theme_name = data.get("theme_name", theme_name)
|
||||
save_theme_yaml(theme_name, theme_yaml)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/theme-google-fonts", methods=["POST"])
|
||||
def update_theme_google_fonts():
|
||||
"""Update only google_fonts in theme.yaml for current theme."""
|
||||
data = request.get_json()
|
||||
theme_name = data.get("theme_name")
|
||||
google_fonts = data.get("google_fonts", [])
|
||||
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
theme_yaml = yaml.safe_load(f)
|
||||
theme_yaml["google_fonts"] = google_fonts
|
||||
with open(theme_yaml_path, "w") as f:
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
@app.route("/api/local-fonts")
|
||||
def api_local_fonts():
|
||||
"""List local fonts for a theme."""
|
||||
theme_name = request.args.get("theme")
|
||||
fonts = get_local_fonts(theme_name)
|
||||
return jsonify(fonts)
|
||||
|
||||
# --- Favicon upload/remove ---
|
||||
@app.route("/api/favicon/upload", methods=["POST"])
|
||||
def upload_favicon():
|
||||
"""Upload favicon for a theme."""
|
||||
theme_name = request.form.get("theme")
|
||||
file = request.files.get("file")
|
||||
if not file or not theme_name:
|
||||
return jsonify({"error": "❌ Missing file or theme"}), 400
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in [".png", ".jpg", ".jpeg", ".ico"]:
|
||||
return jsonify({"error": "❌ Invalid file type"}), 400
|
||||
filename = "favicon" + ext
|
||||
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
|
||||
file.save(theme_dir / filename)
|
||||
theme_yaml_path = theme_dir / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
theme_yaml = yaml.safe_load(f)
|
||||
theme_yaml.setdefault("favicon", {})["path"] = filename
|
||||
with open(theme_yaml_path, "w") as f:
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok", "filename": filename})
|
||||
|
||||
@app.route("/api/favicon/remove", methods=["POST"])
|
||||
def remove_favicon():
|
||||
"""Remove favicon for a theme."""
|
||||
data = request.get_json()
|
||||
theme_name = data.get("theme")
|
||||
if not theme_name:
|
||||
return jsonify({"error": "❌ Missing theme"}), 400
|
||||
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
|
||||
for ext in [".png", ".jpg", ".jpeg", ".ico"]:
|
||||
favicon_path = theme_dir / f"favicon{ext}"
|
||||
if favicon_path.exists():
|
||||
favicon_path.unlink()
|
||||
theme_yaml_path = theme_dir / "theme.yaml"
|
||||
with open(theme_yaml_path, "r") as f:
|
||||
theme_yaml = yaml.safe_load(f)
|
||||
if "favicon" in theme_yaml:
|
||||
theme_yaml["favicon"]["path"] = ""
|
||||
with open(theme_yaml_path, "w") as f:
|
||||
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
# --- Serve theme assets ---
|
||||
@app.route("/themes/<theme>/<filename>")
|
||||
def serve_theme_asset(theme, filename):
|
||||
"""Serve a theme asset file."""
|
||||
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
|
||||
return send_from_directory(theme_dir, filename)
|
||||
|
||||
# --- Font upload/remove ---
|
||||
@app.route("/api/font/upload", methods=["POST"])
|
||||
def upload_font():
|
||||
"""Upload a font file for a theme."""
|
||||
theme_name = request.form.get("theme")
|
||||
file = request.files.get("file")
|
||||
if not file or not theme_name:
|
||||
return jsonify({"error": "❌ Missing theme or font"}), 400
|
||||
ext = Path(file.filename).suffix.lower()
|
||||
if ext not in [".woff", ".woff2"]:
|
||||
return jsonify({"error": "❌ Invalid font file type"}), 400
|
||||
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
||||
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||
file.save(fonts_dir / file.filename)
|
||||
return jsonify({"status": "ok", "filename": file.filename})
|
||||
|
||||
@app.route("/api/font/remove", methods=["POST"])
|
||||
def remove_font():
|
||||
"""Remove a font file for a theme."""
|
||||
data = request.get_json()
|
||||
theme_name = data.get("theme")
|
||||
font = data.get("font")
|
||||
if not theme_name or not font:
|
||||
return jsonify({"error": "❌ Missing theme or font"}), 400
|
||||
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
||||
font_path = fonts_dir / font
|
||||
if font_path.exists():
|
||||
font_path.unlink()
|
||||
return jsonify({"status": "ok"})
|
||||
return jsonify({"error": "❌ Font not found"}), 404
|
||||
|
||||
# --- Build & Download ZIP ---
|
||||
@app.route("/api/build", methods=["POST"])
|
||||
def trigger_build():
|
||||
"""
|
||||
Validate site.yaml and run build.py.
|
||||
Does NOT create zip here; zip is created on demand in download route.
|
||||
"""
|
||||
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||
output_folder = Path(__file__).resolve().parents[3] / "output"
|
||||
|
||||
if not site_yaml_path.exists():
|
||||
return jsonify({"status": "error", "message": "❌ site.yaml not found"}), 400
|
||||
|
||||
with open(site_yaml_path, "r") as f:
|
||||
site_data = yaml.safe_load(f) or {}
|
||||
|
||||
# Dynamically check all main sections and nested keys
|
||||
main_sections = list(site_data.keys())
|
||||
for section in main_sections:
|
||||
value = site_data.get(section)
|
||||
if not value:
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
||||
if isinstance(value, dict):
|
||||
for k, v in value.items():
|
||||
if v is None or v == "" or (isinstance(v, list) and not v):
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}.{k}"}), 400
|
||||
elif isinstance(value, list):
|
||||
if not value:
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
||||
for idx, item in enumerate(value):
|
||||
if isinstance(item, dict):
|
||||
for k, v in item.items():
|
||||
if v is None or v == "" or (isinstance(v, list) and not v):
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}].{k}"}), 400
|
||||
elif item is None or item == "":
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}[{idx}]"}), 400
|
||||
else:
|
||||
if value is None or value == "":
|
||||
return jsonify({"status": "error", "message": f"❌ Site info are not set: missing {section}"}), 400
|
||||
|
||||
try:
|
||||
subprocess.run(["python3", "build.py"], check=True)
|
||||
return jsonify({"status": "ok"})
|
||||
except Exception as e:
|
||||
return jsonify({"status": "error", "message": f"❌ {str(e)}"}), 500
|
||||
|
||||
@app.route("/download-output-zip", methods=["POST"])
|
||||
def download_output_zip():
|
||||
"""
|
||||
Create output zip on demand and send it to the user.
|
||||
Zip is deleted after sending.
|
||||
"""
|
||||
output_folder = Path(__file__).resolve().parents[3] / "output"
|
||||
zip_path = Path(__file__).resolve().parents[3] / "site_output.zip" # Store in lumeex/ root
|
||||
|
||||
# Create zip on demand
|
||||
with zipfile.ZipFile(zip_path, "w") as zipf:
|
||||
for root, dirs, files in os.walk(output_folder):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
zipf.write(file_path, file_path.relative_to(output_folder))
|
||||
|
||||
@after_this_request
|
||||
def remove_file(response):
|
||||
try:
|
||||
os.remove(zip_path)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
return send_file(zip_path, as_attachment=True)
|
||||
|
||||
# --- Run server ---
|
||||
if __name__ == "__main__":
|
||||
logging.info("Starting WebUI at http://0.0.0.0:5000")
|
||||
app.run(host="0.0.0.0", port=5000, debug=True)
|
BIN
src/webui/favicon.ico
Normal file
BIN
src/webui/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.0 KiB |
126
src/webui/gallery-editor/index.html
Normal file
126
src/webui/gallery-editor/index.html
Normal file
@ -0,0 +1,126 @@
|
||||
<!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">
|
||||
<input type="checkbox" id="nav-check">
|
||||
<div class="nav-header">
|
||||
<div class="nav-title">
|
||||
<a href="/"><img src="{{ url_for('static', filename='img/logo.svg') }}"></a>
|
||||
</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"><a href="/gallery-editor">Gallery</a></li>
|
||||
<li class="nav-item"><a href="/site-info">Site info</a></li>
|
||||
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
|
||||
<li class="nav-item">
|
||||
<button id="build-btn" class="button">🚀 Build !</button>
|
||||
</li>
|
||||
</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="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="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>
|
||||
|
||||
<div class="section">
|
||||
<h2>Steps</h2>
|
||||
<p> Follow the steps to generate your static gallery</p>
|
||||
<ul id="stepper">
|
||||
<li><a class="step-active" href="/gallery-editor">Upload your photos</a></li>
|
||||
<div>→</div>
|
||||
<li><a href="/site-info">Configure site info</a></li>
|
||||
<div>→</div>
|
||||
<li><a href="/theme-editor">Customize your theme</a></li>
|
||||
<div>→</div>
|
||||
<li><button id="stepper-build">Generate your static site!</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/upload.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/build.js') }}" defer></script>
|
||||
<!-- Delete confirmation modal -->
|
||||
<div id="delete-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="delete-modal-close" class="modal-close">×</span>
|
||||
<h3>Confirm Deletion</h3>
|
||||
<p id="delete-modal-text">Are you sure you want to delete this image?</p>
|
||||
<div class="modal-actions">
|
||||
<button id="delete-modal-confirm" class="modal-btn danger">Delete</button>
|
||||
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Build success modal -->
|
||||
<div id="build-success-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="build-success-modal-close" class="modal-close">×</span>
|
||||
<h3>✅ Build completed!</h3>
|
||||
<p>Your files are available in the output folder.</p>
|
||||
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
|
||||
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer">
|
||||
<div class="content-inner">
|
||||
<div class="footer-container">
|
||||
<div class="footer-credit">
|
||||
<p><a href="https//lumeex.djeex.fr"><span class="lum-first">Lum</span><span class="lum-second">eex</span> v{{ lumeex_version }}</a> — © 2025</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
|
||||
<a class="footer-link gitea" href="https://gitea.com/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
|
||||
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
154
src/webui/img/favicon.svg
Normal file
154
src/webui/img/favicon.svg
Normal file
@ -0,0 +1,154 @@
|
||||
<?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 1000 1000">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: url(#Dégradé_sans_nom_265);
|
||||
stroke: url(#Dégradé_sans_nom_33);
|
||||
}
|
||||
|
||||
.st0, .st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11 {
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: url(#Dégradé_sans_nom_269);
|
||||
stroke: url(#Dégradé_sans_nom_334);
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: url(#Dégradé_sans_nom_268);
|
||||
stroke: url(#Dégradé_sans_nom_333);
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: url(#Dégradé_sans_nom_266);
|
||||
stroke: url(#Dégradé_sans_nom_331);
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: url(#Dégradé_sans_nom_267);
|
||||
stroke: url(#Dégradé_sans_nom_332);
|
||||
}
|
||||
|
||||
.st12 {
|
||||
fill: url(#Dégradé_sans_nom_261);
|
||||
}
|
||||
|
||||
.st13 {
|
||||
fill: url(#Dégradé_sans_nom_262);
|
||||
}
|
||||
|
||||
.st14 {
|
||||
fill: url(#Dégradé_sans_nom_264);
|
||||
}
|
||||
|
||||
.st15 {
|
||||
fill: url(#Dégradé_sans_nom_263);
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: url(#Dégradé_sans_nom_2616);
|
||||
stroke: url(#Dégradé_sans_nom_3311);
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: url(#Dégradé_sans_nom_2615);
|
||||
stroke: url(#Dégradé_sans_nom_3310);
|
||||
}
|
||||
|
||||
.st16 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st17 {
|
||||
fill: url(#Dégradé_sans_nom_26);
|
||||
}
|
||||
|
||||
.st7 {
|
||||
fill: url(#Dégradé_sans_nom_2610);
|
||||
stroke: url(#Dégradé_sans_nom_335);
|
||||
}
|
||||
|
||||
.st8 {
|
||||
fill: url(#Dégradé_sans_nom_2613);
|
||||
stroke: url(#Dégradé_sans_nom_338);
|
||||
}
|
||||
|
||||
.st9 {
|
||||
fill: url(#Dégradé_sans_nom_2614);
|
||||
stroke: url(#Dégradé_sans_nom_339);
|
||||
}
|
||||
|
||||
.st10 {
|
||||
fill: url(#Dégradé_sans_nom_2611);
|
||||
stroke: url(#Dégradé_sans_nom_336);
|
||||
}
|
||||
|
||||
.st11 {
|
||||
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="st16" cx="499.5" cy="499.5" r="499.5"/>
|
||||
</g>
|
||||
<g id="Calque_2">
|
||||
<g id="Calque_3">
|
||||
<ellipse class="st17" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
|
||||
<ellipse class="st12" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
|
||||
<ellipse class="st13" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
|
||||
<ellipse class="st15" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
|
||||
<ellipse class="st14" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
|
||||
<circle class="st0" cx="90.4" cy="576" r="22.4"/>
|
||||
<circle class="st3" cx="175.6" cy="607.9" r="13.1"/>
|
||||
<circle class="st4" cx="140.8" cy="691.6" r="28"/>
|
||||
<circle class="st2" cx="829.7" cy="602.6" r="28"/>
|
||||
<circle class="st1" cx="908.9" cy="562.1" r="13.1"/>
|
||||
<circle class="st7" cx="840.9" cy="698.1" r="22.4"/>
|
||||
<circle class="st10" cx="466.1" cy="876.5" r="22.5"/>
|
||||
<circle class="st11" cx="538.6" cy="839.8" r="13.1"/>
|
||||
<circle class="st8" cx="686.1" cy="170.1" r="28"/>
|
||||
<circle class="st9" cx="733.7" cy="247.7" r="13.1"/>
|
||||
<circle class="st6" cx="236.9" cy="206.5" r="21.1"/>
|
||||
<circle class="st5" cx="315.4" cy="164.9" r="13.1"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 9.6 KiB |
2
src/webui/img/gitea.svg
Normal file
2
src/webui/img/gitea.svg
Normal file
@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#48cf51ff" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Gitea icon</title><path d="M4.186 5.421C2.341 5.417-.13 6.59.006 9.531c.213 4.594 4.92 5.02 6.801 5.057.206.862 2.42 3.834 4.059 3.99h7.18c4.306-.286 7.53-13.022 5.14-13.07-3.953.186-6.296.28-8.305.296v3.975l-.626-.277-.004-3.696c-2.306-.001-4.336-.108-8.189-.298-.482-.003-1.154-.085-1.876-.087zm.261 1.625h.22c.262 2.355.688 3.732 1.55 5.836-2.2-.26-4.072-.899-4.416-3.285-.178-1.235.422-2.524 2.646-2.552zm8.557 2.315c.15.002.303.03.447.096l.749.323-.537.979a.672.597 0 0 0-.241.038.672.597 0 0 0-.405.764.672.597 0 0 0 .112.174l-.926 1.686a.672.597 0 0 0-.222.038.672.597 0 0 0-.405.764.672.597 0 0 0 .86.36.672.597 0 0 0 .404-.765.672.597 0 0 0-.158-.22l.902-1.642a.672.597 0 0 0 .293-.03.672.597 0 0 0 .213-.112c.348.146.633.265.838.366.308.152.417.253.45.365.033.11-.003.322-.177.694-.13.277-.345.67-.599 1.133a.672.597 0 0 0-.251.038.672.597 0 0 0-.405.764.672.597 0 0 0 .86.36.672.597 0 0 0 .404-.764.672.597 0 0 0-.137-.202c.251-.458.467-.852.606-1.148.188-.402.286-.701.2-.99-.086-.289-.35-.477-.7-.65-.23-.113-.517-.233-.86-.377a.672.597 0 0 0-.038-.239.672.597 0 0 0-.145-.209l.528-.963 2.924 1.263c.528.229.746.79.49 1.26l-2.01 3.68c-.257.469-.888.663-1.416.435l-4.137-1.788c-.528-.228-.747-.79-.49-1.26l2.01-3.679c.176-.323.53-.515.905-.53h.064z"/></svg>
|
After Width: | Height: | Size: 1.4 KiB |
19
src/webui/img/github.svg
Normal file
19
src/webui/img/github.svg
Normal file
@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
|
||||
<title>github [#142]</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Dribbble-Light-Preview" transform="translate(-140.000000, -7559.000000)" fill="#ffffffff">
|
||||
<g id="icons" transform="translate(56.000000, 160.000000)">
|
||||
<path d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399" id="github-[#142]">
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.9 KiB |
166
src/webui/img/logo.svg
Normal file
166
src/webui/img/logo.svg
Normal file
@ -0,0 +1,166 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 6031 1000">
|
||||
<!-- Generator: Adobe Illustrator 29.7.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 8) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #55c3ec;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: url(#Dégradé_sans_nom_265);
|
||||
stroke: url(#Dégradé_sans_nom_33);
|
||||
}
|
||||
|
||||
.st1, .st2, .st3, .st4, .st5, .st6, .st7, .st8, .st9, .st10, .st11, .st12 {
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: url(#Dégradé_sans_nom_269);
|
||||
stroke: url(#Dégradé_sans_nom_334);
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: url(#Dégradé_sans_nom_268);
|
||||
stroke: url(#Dégradé_sans_nom_333);
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: url(#Dégradé_sans_nom_266);
|
||||
stroke: url(#Dégradé_sans_nom_331);
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: url(#Dégradé_sans_nom_267);
|
||||
stroke: url(#Dégradé_sans_nom_332);
|
||||
}
|
||||
|
||||
.st13 {
|
||||
fill: url(#Dégradé_sans_nom_261);
|
||||
}
|
||||
|
||||
.st14 {
|
||||
fill: url(#Dégradé_sans_nom_262);
|
||||
}
|
||||
|
||||
.st15 {
|
||||
fill: url(#Dégradé_sans_nom_264);
|
||||
}
|
||||
|
||||
.st16 {
|
||||
fill: url(#Dégradé_sans_nom_263);
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: url(#Dégradé_sans_nom_2616);
|
||||
stroke: url(#Dégradé_sans_nom_3311);
|
||||
}
|
||||
|
||||
.st7 {
|
||||
fill: url(#Dégradé_sans_nom_2615);
|
||||
stroke: url(#Dégradé_sans_nom_3310);
|
||||
}
|
||||
|
||||
.st17 {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
.st18 {
|
||||
fill: url(#Dégradé_sans_nom_26);
|
||||
}
|
||||
|
||||
.st8 {
|
||||
fill: url(#Dégradé_sans_nom_2610);
|
||||
stroke: url(#Dégradé_sans_nom_335);
|
||||
}
|
||||
|
||||
.st9 {
|
||||
fill: url(#Dégradé_sans_nom_2613);
|
||||
stroke: url(#Dégradé_sans_nom_338);
|
||||
}
|
||||
|
||||
.st10 {
|
||||
fill: url(#Dégradé_sans_nom_2614);
|
||||
stroke: url(#Dégradé_sans_nom_339);
|
||||
}
|
||||
|
||||
.st11 {
|
||||
fill: url(#Dégradé_sans_nom_2611);
|
||||
stroke: url(#Dégradé_sans_nom_336);
|
||||
}
|
||||
|
||||
.st12 {
|
||||
fill: url(#Dégradé_sans_nom_2612);
|
||||
stroke: url(#Dégradé_sans_nom_337);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="Dégradé_sans_nom_26" data-name="Dégradé sans nom 26" x1="373.2" y1="159.5" x2="625.1" y2="411.5" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#55c3ec"/>
|
||||
<stop offset="1" stop-color="#1d71b8" stop-opacity=".8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="Dégradé_sans_nom_261" data-name="Dégradé sans nom 26" x1="143.1" y1="200.5" x2="395" y2="452.4" gradientTransform="translate(30.8 109.3)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_262" data-name="Dégradé sans nom 26" x1="81.3" y1="60.6" x2="333.2" y2="312.5" gradientTransform="translate(187.1 873.6) rotate(-90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_263" data-name="Dégradé sans nom 26" x1="-44.4" y1="16.5" x2="207.5" y2="268.4" gradientTransform="translate(705.4 808.2) rotate(-180)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_264" data-name="Dégradé sans nom 26" x1="-67.9" y1="-58.9" x2="184" y2="193" gradientTransform="translate(770.9 385.1) rotate(90)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_265" data-name="Dégradé sans nom 26" x1="74.5" y1="560.2" x2="106.2" y2="591.8" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_33" data-name="Dégradé sans nom 33" x1="74.2" y1="559.9" x2="106.5" y2="592.2" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#55c3ec"/>
|
||||
<stop offset="1" stop-color="#1d71b8" stop-opacity=".5"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="Dégradé_sans_nom_266" data-name="Dégradé sans nom 26" x1="158.2" y1="648.8" x2="176.7" y2="667.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_331" data-name="Dégradé sans nom 33" x1="157.8" y1="648.4" x2="177.1" y2="667.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_267" data-name="Dégradé sans nom 26" x1="210.2" y1="714.7" x2="249.8" y2="754.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_332" data-name="Dégradé sans nom 33" x1="209.9" y1="714.3" x2="250.2" y2="754.6" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_268" data-name="Dégradé sans nom 26" x1="-54" y1="72.2" x2="-14.4" y2="111.8" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_333" data-name="Dégradé sans nom 33" x1="-54.4" y1="71.9" x2="-14.1" y2="112.2" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_269" data-name="Dégradé sans nom 26" x1="485.1" y1="-9.2" x2="503.7" y2="9.4" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_334" data-name="Dégradé sans nom 33" x1="484.8" y1="-9.6" x2="504" y2="9.7" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_2610" data-name="Dégradé sans nom 26" x1="825.1" y1="682.3" x2="856.8" y2="713.9" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_335" data-name="Dégradé sans nom 33" x1="824.8" y1="681.9" x2="857.1" y2="714.3" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_2611" data-name="Dégradé sans nom 26" x1="308.5" y1="356.5" x2="340.3" y2="388.3" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_336" data-name="Dégradé sans nom 33" x1="308.1" y1="356.2" x2="340.7" y2="388.7" gradientTransform="translate(909.8 659.5) rotate(105)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_2612" data-name="Dégradé sans nom 26" x1="540.4" y1="450.4" x2="559" y2="469" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_337" data-name="Dégradé sans nom 33" x1="540.1" y1="450" x2="559.4" y2="469.3" gradientTransform="translate(661.8 133.9) rotate(60)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_2613" data-name="Dégradé sans nom 26" x1="-336.4" y1="326.9" x2="-296.8" y2="366.5" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_338" data-name="Dégradé sans nom 33" x1="-336.7" y1="326.5" x2="-296.4" y2="366.8" gradientTransform="translate(342.9 490.5) rotate(-175.4)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_2614" data-name="Dégradé sans nom 26" x1="115" y1="-29.9" x2="133.6" y2="-11.3" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_339" data-name="Dégradé sans nom 33" x1="114.7" y1="-30.2" x2="134" y2="-11" gradientTransform="translate(814.9 151.4) rotate(139.6)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_2615" data-name="Dégradé sans nom 26" x1="94.5" y1="304.2" x2="124.4" y2="334" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_3310" data-name="Dégradé sans nom 33" x1="94.2" y1="303.8" x2="124.7" y2="334.4" gradientTransform="translate(568 142) rotate(97.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
<linearGradient id="Dégradé_sans_nom_2616" data-name="Dégradé sans nom 26" x1="435.8" y1="254.1" x2="454.4" y2="272.7" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_26"/>
|
||||
<linearGradient id="Dégradé_sans_nom_3311" data-name="Dégradé sans nom 33" x1="435.5" y1="253.8" x2="454.8" y2="273.1" gradientTransform="translate(257.1 -349) rotate(52.9)" xlink:href="#Dégradé_sans_nom_33"/>
|
||||
</defs>
|
||||
<g id="Calque_1">
|
||||
<circle class="st17" cx="499.5" cy="499.5" r="499.5"/>
|
||||
<g>
|
||||
<path class="st17" d="M1404,957.4V45h191v755h399v157.4h-590Z"/>
|
||||
<path class="st17" d="M2321.5,971.3c-49.3,0-91.5-10.2-126.5-30.7-35-20.4-61.6-49.6-79.7-87.6-18.1-37.9-27.2-83.2-27.2-135.9v-437.6h184.6v399c0,44.3,10.4,78.6,31.3,103.1,20.9,24.5,51.9,36.7,93.3,36.7s39.3-3.6,56-10.7c16.6-7.2,30.9-17.4,42.7-30.7,11.8-13.3,20.9-29.1,27.2-47.4s9.5-38.5,9.5-60.4v-389.5h184.6v677.8h-184.6v-111.9h-4.4c-11.4,25.7-26.7,48.1-45.8,67-19.2,19-42.2,33.5-68.9,43.6-26.8,10.1-57.4,15.2-92,15.2Z"/>
|
||||
<path class="st17" d="M2837.5,957.4V279.6h184.6v113.8h3.8c13.9-38.8,37.6-69.8,71.1-93,33.5-23.2,73-34.8,118.6-34.8s60.1,5.5,85.4,16.4c25.3,11,46.7,26.8,64.2,47.4,17.5,20.7,29.8,46,37,75.9h3.8c10.1-28.7,25.4-53.4,45.8-74.3,20.4-20.9,44.7-37,72.7-48.4,28-11.4,58.7-17.1,92-17.1s83,9.5,116.3,28.5c33.3,19,59.2,45.5,77.8,79.7,18.5,34.1,27.8,74.2,27.8,120.1v463.5h-184.6v-416.7c0-26.6-4.3-48.8-13-66.7-8.6-17.9-21.1-31.6-37.3-41.1-16.2-9.5-36.4-14.2-60.4-14.2s-43.5,5.4-61,16.1c-17.5,10.7-31.1,25.6-40.8,44.6-9.7,19-14.5,41.1-14.5,66.4v411.6h-177.7v-422.4c0-24.4-4.4-45.3-13.3-62.6-8.9-17.3-21.4-30.6-37.6-39.8-16.2-9.3-35.7-13.9-58.5-13.9s-43.6,5.6-61.3,16.8c-17.7,11.2-31.5,26.5-41.4,45.8-9.9,19.4-14.9,41.7-14.9,67v409.1h-184.6Z"/>
|
||||
<path class="st0" d="M4264,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6-36,12.6-78.3,19-126.8,19Z"/>
|
||||
<path class="st0" d="M4982.9,971.3c-69.1,0-128.6-14.2-178.3-42.7-49.7-28.5-88.1-69-115.1-121.7-27-52.7-40.5-115.1-40.5-187.2v-.6c0-72.1,13.5-134.6,40.5-187.5,27-52.9,64.8-93.8,113.5-122.7,48.7-28.9,106.1-43.3,172.3-43.3s123.4,14,171.7,42c48.3,28,85.6,67.6,111.9,118.6,26.3,51,39.5,110.7,39.5,178.9v57.5h-558.3v-117h471.1l-87.9,109.4v-71.5c0-39.6-6.1-72.8-18.3-99.6-12.2-26.8-29.2-46.9-50.9-60.4-21.7-13.5-46.9-20.2-75.6-20.2s-54.1,7-76.2,20.9c-22.1,13.9-39.4,34.3-51.9,61-12.4,26.8-18.7,59.5-18.7,98.3v72.1c0,37.1,6.2,68.9,18.7,95.5,12.4,26.6,30.2,46.9,53.4,61,23.2,14.1,50.8,21.2,82.8,21.2s47.2-4,65.8-12c18.5-8,33.7-18.1,45.5-30.4,11.8-12.2,19.8-24.7,24-37.3l1.3-3.8h169.5l-1.9,7c-5.1,24.9-15,50-29.7,75.2-14.8,25.3-34.7,48.5-59.8,69.6-25.1,21.1-55.6,37.9-91.7,50.6s-78.3,19-126.8,19Z"/>
|
||||
<path class="st0" d="M5337,957.4l212.5-337.7-210.6-340.2h208l122,228.9h3.8l120.1-228.9h201.1l-211.8,335.1,209.9,342.7h-200.4l-129-234.6h-3.8l-127.1,234.6h-194.8Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Calque_2">
|
||||
<g id="Calque_3">
|
||||
<ellipse class="st18" cx="499.2" cy="285.5" rx="139.8" ry="209.5"/>
|
||||
<ellipse class="st13" cx="299.9" cy="435.8" rx="139.8" ry="209.5" transform="translate(-207.3 586.3) rotate(-72)"/>
|
||||
<ellipse class="st14" cx="373.6" cy="666.3" rx="209.5" ry="139.8" transform="translate(-385.1 576.9) rotate(-54)"/>
|
||||
<ellipse class="st16" cx="623.9" cy="665.8" rx="139.8" ry="209.5" transform="translate(-272.2 493.9) rotate(-36)"/>
|
||||
<ellipse class="st15" cx="703.9" cy="443.1" rx="209.5" ry="139.8" transform="translate(-94.9 211.2) rotate(-16)"/>
|
||||
<circle class="st1" cx="90.4" cy="576" r="22.4"/>
|
||||
<circle class="st4" cx="175.6" cy="607.9" r="13.1"/>
|
||||
<circle class="st5" cx="140.8" cy="691.6" r="28"/>
|
||||
<circle class="st3" cx="829.7" cy="602.6" r="28"/>
|
||||
<circle class="st2" cx="908.9" cy="562.1" r="13.1"/>
|
||||
<circle class="st8" cx="840.9" cy="698.1" r="22.4"/>
|
||||
<circle class="st11" cx="466.1" cy="876.5" r="22.5"/>
|
||||
<circle class="st12" cx="538.6" cy="839.8" r="13.1"/>
|
||||
<circle class="st9" cx="686.1" cy="170.1" r="28"/>
|
||||
<circle class="st10" cx="733.7" cy="247.7" r="13.1"/>
|
||||
<circle class="st7" cx="236.9" cy="206.5" r="21.1"/>
|
||||
<circle class="st6" cx="315.4" cy="164.9" r="13.1"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 13 KiB |
85
src/webui/index.html
Normal file
85
src/webui/index.html
Normal file
@ -0,0 +1,85 @@
|
||||
<!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">
|
||||
<input type="checkbox" id="nav-check">
|
||||
<div class="nav-header">
|
||||
<div class="nav-title">
|
||||
<a href="/"><img src="{{ url_for('static', filename='img/logo.svg') }}"></a>
|
||||
</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"><a href="/gallery-editor">Gallery</a></li>
|
||||
<li class="nav-item"><a href="/site-info">Site info</a></li>
|
||||
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
|
||||
<li class="nav-item">
|
||||
<button id="build-btn" class="button">🚀 Build !</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Toast container for notifications -->
|
||||
<div class="content-inner first-content">
|
||||
<div id="toast-container"></div>
|
||||
|
||||
<h1>Static Gallery Generator</h1>
|
||||
<p>Use this generator to create a static gallery from your photos. Then, upload the static files to your preferred web server.</p>
|
||||
|
||||
<!-- Hero Upload Section -->
|
||||
<div class="section">
|
||||
<h2>Steps</h2>
|
||||
<p> Follow the steps to generate your static gallery</p>
|
||||
<ul id="stepper">
|
||||
<li><a href="/gallery-editor">Upload your photos</a></li>
|
||||
<div>→</div>
|
||||
<li><a href="/site-info">Configure site info</a></li>
|
||||
<div>→</div>
|
||||
<li><a href="/theme-editor">Customize your theme</a></li>
|
||||
<div>→</div>
|
||||
<li><button id="stepper-build">Generate your static site!</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Build success modal -->
|
||||
<div id="build-success-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="build-success-modal-close" class="modal-close">×</span>
|
||||
<h3>✅ Build completed!</h3>
|
||||
<p>Your files are available in the output folder.</p>
|
||||
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
|
||||
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer">
|
||||
<div class="content-inner">
|
||||
<div class="footer-container">
|
||||
<div class="footer-credit">
|
||||
<p><a href="https//lumeex.djeex.fr"><span class="lum-first">Lum</span><span class="lum-second">eex</span> v{{ lumeex_version }}</a> — © 2025</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
|
||||
<a class="footer-link gitea" href="https://gitea.com/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
|
||||
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
89
src/webui/js/build.js
Normal file
89
src/webui/js/build.js
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Show a toast notification.
|
||||
* @param {string} message - The message to display.
|
||||
* @param {string} type - "success" or "error".
|
||||
* @param {number} duration - Duration in ms.
|
||||
*/
|
||||
function showToast(message, type = "success", duration = 3000) {
|
||||
const container = document.getElementById("toast-container");
|
||||
if (!container) return;
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
requestAnimationFrame(() => toast.classList.add("show"));
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
setTimeout(() => container.removeChild(toast), 300);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Get build button and modal elements
|
||||
const buildBtn = document.getElementById("build-btn");
|
||||
const stepperBuildBtn = document.getElementById("stepper-build"); // Added for stepper build button
|
||||
const buildModal = document.getElementById("build-success-modal");
|
||||
const buildModalClose = document.getElementById("build-success-modal-close");
|
||||
const downloadZipBtn = document.getElementById("download-zip-btn");
|
||||
const zipLoader = document.getElementById("zip-loader");
|
||||
|
||||
// Build action handler
|
||||
async function handleBuildClick() {
|
||||
// Trigger build on backend
|
||||
const res = await fetch("/api/build", { method: "POST" });
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
// Show build success modal
|
||||
if (buildModal) buildModal.style.display = "flex";
|
||||
} else {
|
||||
showToast(result.message || "❌ Build failed!", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle build button click
|
||||
if (buildBtn) {
|
||||
buildBtn.addEventListener("click", handleBuildClick);
|
||||
}
|
||||
// Handle stepper-build button click
|
||||
if (stepperBuildBtn) {
|
||||
stepperBuildBtn.addEventListener("click", handleBuildClick);
|
||||
}
|
||||
|
||||
// Handle download zip button click
|
||||
if (downloadZipBtn) {
|
||||
downloadZipBtn.addEventListener("click", async () => {
|
||||
if (zipLoader) zipLoader.style.display = "block";
|
||||
downloadZipBtn.disabled = true;
|
||||
|
||||
// Request zip creation and download from backend
|
||||
const res = await fetch("/download-output-zip", { method: "POST" });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "site_output.zip";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
} else {
|
||||
showToast("❌ Error creating ZIP", "error");
|
||||
}
|
||||
if (zipLoader) zipLoader.style.display = "none";
|
||||
downloadZipBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Modal close logic
|
||||
if (buildModal && buildModalClose) {
|
||||
buildModalClose.onclick = () => {
|
||||
buildModal.style.display = "none";
|
||||
};
|
||||
window.onclick = function(event) {
|
||||
if (event.target === buildModal) {
|
||||
buildModal.style.display = "none";
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
411
src/webui/js/main.js
Normal file
411
src/webui/js/main.js
Normal file
@ -0,0 +1,411 @@
|
||||
// --- Arrays to store gallery and hero images ---
|
||||
let galleryImages = [];
|
||||
let heroImages = [];
|
||||
let allTags = []; // global tag list
|
||||
|
||||
// --- Load images from server on page load ---
|
||||
async function loadData() {
|
||||
try {
|
||||
const galleryRes = await fetch('/api/gallery');
|
||||
galleryImages = await galleryRes.json();
|
||||
updateAllTags();
|
||||
renderGallery();
|
||||
|
||||
const heroRes = await fetch('/api/hero');
|
||||
heroImages = await heroRes.json();
|
||||
renderHero();
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showToast("Error loading images!", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update global tag list from galleryImages ---
|
||||
function updateAllTags() {
|
||||
allTags = [];
|
||||
galleryImages.forEach(img => {
|
||||
if (img.tags) img.tags.forEach(t => {
|
||||
if (!allTags.includes(t)) allTags.push(t);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Render gallery images with tags and delete buttons ---
|
||||
function renderGallery() {
|
||||
const container = document.getElementById('gallery');
|
||||
container.innerHTML = '';
|
||||
galleryImages.forEach((img, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'photo flex-item flex-column';
|
||||
div.innerHTML = `
|
||||
<div class="flex-item">
|
||||
<img src="/photos/${img.src}">
|
||||
</div>
|
||||
<div class="tags-display" data-index="${i}"></div>
|
||||
<div class="flex-item flex-full">
|
||||
<div class="flex-item flex-end">
|
||||
<button onclick="showDeleteModal('gallery', ${i})">🗑 Delete</button>
|
||||
</div>
|
||||
<div class="tag-input" data-index="${i}"></div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
renderTags(i, img.tags || []);
|
||||
});
|
||||
|
||||
// Show/hide Remove All button
|
||||
const removeAllBtn = document.getElementById('remove-all-gallery');
|
||||
if (removeAllBtn) {
|
||||
removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Render tags for a single image ---
|
||||
function renderTags(imgIndex, tags) {
|
||||
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
|
||||
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
|
||||
|
||||
tagsDisplay.innerHTML = '';
|
||||
inputContainer.innerHTML = '';
|
||||
|
||||
tags.forEach(tag => {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag';
|
||||
span.textContent = tag;
|
||||
|
||||
const remove = document.createElement('span');
|
||||
remove.className = 'remove-tag';
|
||||
remove.textContent = '×';
|
||||
remove.onclick = () => {
|
||||
tags.splice(tags.indexOf(tag), 1);
|
||||
updateTags(imgIndex, tags);
|
||||
renderTags(imgIndex, tags);
|
||||
};
|
||||
|
||||
span.appendChild(remove);
|
||||
tagsDisplay.appendChild(span);
|
||||
});
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.placeholder = 'Add tag...';
|
||||
inputContainer.appendChild(input);
|
||||
|
||||
const suggestionBox = document.createElement('ul');
|
||||
suggestionBox.className = 'suggestions';
|
||||
inputContainer.appendChild(suggestionBox);
|
||||
|
||||
let selectedIndex = -1;
|
||||
|
||||
const addTag = (tag) => {
|
||||
tag = tag.trim();
|
||||
if (!tag) return;
|
||||
if (!tags.includes(tag)) tags.push(tag);
|
||||
updateTags(imgIndex, tags);
|
||||
renderTags(imgIndex, tags);
|
||||
};
|
||||
|
||||
const updateSuggestions = () => {
|
||||
const value = input.value.toLowerCase();
|
||||
|
||||
const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
|
||||
const tagCount = {};
|
||||
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
|
||||
|
||||
const allTagsSorted = Object.keys(tagCount)
|
||||
.sort((a, b) => tagCount[b] - tagCount[a]);
|
||||
|
||||
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
|
||||
|
||||
suggestionBox.innerHTML = '';
|
||||
selectedIndex = -1;
|
||||
|
||||
if (suggestions.length) {
|
||||
suggestionBox.style.display = 'block';
|
||||
suggestions.forEach((s, idx) => {
|
||||
const li = document.createElement('li');
|
||||
li.style.fontStyle = 'italic';
|
||||
li.style.textAlign = 'left';
|
||||
|
||||
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
|
||||
const rest = s.substring(input.value.length);
|
||||
li.innerHTML = boldPart + rest;
|
||||
|
||||
li.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
addTag(s);
|
||||
input.value = '';
|
||||
input.focus();
|
||||
updateSuggestions();
|
||||
});
|
||||
|
||||
li.onmouseover = () => selectedIndex = idx;
|
||||
suggestionBox.appendChild(li);
|
||||
});
|
||||
} else {
|
||||
suggestionBox.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', updateSuggestions);
|
||||
input.addEventListener('focus', updateSuggestions);
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = suggestionBox.querySelectorAll('li');
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!items.length) return;
|
||||
selectedIndex = (selectedIndex + 1) % items.length;
|
||||
items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!items.length) return;
|
||||
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
|
||||
items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && items[selectedIndex]) {
|
||||
addTag(items[selectedIndex].textContent);
|
||||
} else {
|
||||
addTag(input.value);
|
||||
}
|
||||
input.value = '';
|
||||
updateSuggestions();
|
||||
} else if ([' ', ','].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
addTag(input.value);
|
||||
input.value = '';
|
||||
updateSuggestions();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
suggestionBox.style.display = 'none';
|
||||
input.value = '';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
input.focus();
|
||||
updateSuggestions();
|
||||
}
|
||||
|
||||
// --- Update tags in galleryImages array ---
|
||||
function updateTags(index, tags) {
|
||||
galleryImages[index].tags = tags;
|
||||
saveGallery();
|
||||
}
|
||||
|
||||
// --- Render hero images with delete buttons ---
|
||||
function renderHero() {
|
||||
const container = document.getElementById('hero');
|
||||
container.innerHTML = '';
|
||||
heroImages.forEach((img, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'photo flex-item flex-column';
|
||||
div.innerHTML = `
|
||||
<div class="flex-item">
|
||||
<img src="/photos/${img.src}">
|
||||
</div>
|
||||
<div class="flex-item flex-full">
|
||||
<div class="flex-item flex-end">
|
||||
<button onclick="showDeleteModal('hero', ${i})">🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// Show/hide Remove All button
|
||||
const removeAllBtn = document.getElementById('remove-all-hero');
|
||||
if (removeAllBtn) {
|
||||
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// --- Save gallery to server ---
|
||||
async function saveGallery() {
|
||||
await fetch('/api/gallery/update', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(galleryImages)
|
||||
});
|
||||
}
|
||||
|
||||
// --- Save hero to server ---
|
||||
async function saveHero() {
|
||||
await fetch('/api/hero/update', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(heroImages)
|
||||
});
|
||||
}
|
||||
|
||||
// --- Save all changes ---
|
||||
async function saveChanges() {
|
||||
await saveGallery();
|
||||
await saveHero();
|
||||
showToast('✅ Changes saved!', "success");
|
||||
}
|
||||
|
||||
// --- Refresh gallery from folder ---
|
||||
async function refreshGallery() {
|
||||
await fetch('/api/gallery/refresh', { method: 'POST' });
|
||||
await loadData();
|
||||
showToast('🔄 Gallery updated from photos/gallery folder', "success");
|
||||
}
|
||||
|
||||
// --- Refresh hero from folder ---
|
||||
async function refreshHero() {
|
||||
await fetch('/api/hero/refresh', { method: 'POST' });
|
||||
await loadData();
|
||||
showToast('🔄 Hero updated from photos/hero folder', "success");
|
||||
}
|
||||
|
||||
// --- Show toast notification ---
|
||||
function showToast(message, type = "success", duration = 3000) {
|
||||
const container = document.getElementById("toast-container");
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add("show"));
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
toast.addEventListener("transitionend", () => toast.remove());
|
||||
}, duration);
|
||||
}
|
||||
|
||||
let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null }
|
||||
|
||||
// --- Show delete confirmation modal ---
|
||||
function showDeleteModal(type, index = null) {
|
||||
pendingDelete = { type, index };
|
||||
const modalText = document.getElementById('delete-modal-text');
|
||||
if (type === 'gallery-all') {
|
||||
modalText.textContent = "Are you sure you want to delete ALL gallery images?";
|
||||
} else if (type === 'hero-all') {
|
||||
modalText.textContent = "Are you sure you want to delete ALL hero images?";
|
||||
} else {
|
||||
modalText.textContent = "Are you sure you want to delete this image?";
|
||||
}
|
||||
document.getElementById('delete-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
// --- Hide modal ---
|
||||
function hideDeleteModal() {
|
||||
document.getElementById('delete-modal').style.display = 'none';
|
||||
pendingDelete = null;
|
||||
}
|
||||
|
||||
// --- Confirm deletion ---
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete) return;
|
||||
if (pendingDelete.type === 'gallery') {
|
||||
await actuallyDeleteGalleryImage(pendingDelete.index);
|
||||
} else if (pendingDelete.type === 'hero') {
|
||||
await actuallyDeleteHeroImage(pendingDelete.index);
|
||||
} else if (pendingDelete.type === 'gallery-all') {
|
||||
await actuallyDeleteAllGalleryImages();
|
||||
} else if (pendingDelete.type === 'hero-all') {
|
||||
await actuallyDeleteAllHeroImages();
|
||||
}
|
||||
hideDeleteModal();
|
||||
}
|
||||
|
||||
// --- Actual delete functions ---
|
||||
async function actuallyDeleteGalleryImage(index) {
|
||||
const img = galleryImages[index];
|
||||
try {
|
||||
const res = await fetch('/api/gallery/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ src: img.src.split('/').pop() })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
galleryImages.splice(index, 1);
|
||||
renderGallery();
|
||||
await saveGallery();
|
||||
showToast("✅ Gallery image deleted!", "success");
|
||||
} else showToast("Error: " + data.error, "error");
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showToast("Server error!", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function actuallyDeleteHeroImage(index) {
|
||||
const img = heroImages[index];
|
||||
try {
|
||||
const res = await fetch('/api/hero/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ src: img.src.split('/').pop() })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
heroImages.splice(index, 1);
|
||||
renderHero();
|
||||
await saveHero();
|
||||
showToast("✅ Hero image deleted!", "success");
|
||||
} else showToast("Error: " + data.error, "error");
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showToast("Server error!", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bulk delete functions ---
|
||||
async function actuallyDeleteAllGalleryImages() {
|
||||
try {
|
||||
const res = await fetch('/api/gallery/delete_all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
galleryImages = [];
|
||||
renderGallery();
|
||||
await saveGallery();
|
||||
showToast("✅ All gallery images removed!", "success");
|
||||
} else showToast("Error: " + data.error, "error");
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showToast("Server error!", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function actuallyDeleteAllHeroImages() {
|
||||
try {
|
||||
const res = await fetch('/api/hero/delete_all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
heroImages = [];
|
||||
renderHero();
|
||||
await saveHero();
|
||||
showToast("✅ All hero images removed!", "success");
|
||||
} else showToast("Error: " + data.error, "error");
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showToast("Server error!", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// --- Modal event listeners and bulk delete buttons ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('delete-modal-close').onclick = hideDeleteModal;
|
||||
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
|
||||
document.getElementById('delete-modal-confirm').onclick = confirmDelete;
|
||||
|
||||
// Bulk delete buttons
|
||||
const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
|
||||
const removeAllHeroBtn = document.getElementById('remove-all-hero');
|
||||
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
|
||||
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
|
||||
});
|
||||
|
||||
// --- Initialize ---
|
||||
loadData();
|
433
src/webui/js/site-info.js
Normal file
433
src/webui/js/site-info.js
Normal file
@ -0,0 +1,433 @@
|
||||
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" required 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");
|
||||
|
||||
// Modal elements for theme deletion
|
||||
const deleteThemeModal = document.getElementById("delete-theme-modal");
|
||||
const deleteThemeModalClose = document.getElementById("delete-theme-modal-close");
|
||||
const deleteThemeModalConfirm = document.getElementById("delete-theme-modal-confirm");
|
||||
const deleteThemeModalCancel = document.getElementById("delete-theme-modal-cancel");
|
||||
const deleteThemeModalText = document.getElementById("delete-theme-modal-text");
|
||||
let themeToDelete = null;
|
||||
|
||||
// Show/hide thumbnail preview, remove button, and choose button
|
||||
function updateThumbnailPreview(src) {
|
||||
if (thumbnailPreview) {
|
||||
thumbnailPreview.src = src || "";
|
||||
thumbnailPreview.style.display = src ? "block" : "none";
|
||||
}
|
||||
if (removeThumbnailBtn) {
|
||||
removeThumbnailBtn.style.display = src ? "inline-block" : "none";
|
||||
}
|
||||
if (chooseThumbnailBtn) {
|
||||
chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
|
||||
}
|
||||
}
|
||||
|
||||
// Choose thumbnail button triggers file input
|
||||
if (chooseThumbnailBtn && thumbnailUpload) {
|
||||
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
|
||||
}
|
||||
|
||||
// Handle thumbnail upload and refresh preview (with cache busting)
|
||||
if (thumbnailUpload) {
|
||||
thumbnailUpload.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove theme button triggers modal
|
||||
const removeThemeBtn = document.getElementById("remove-theme-btn");
|
||||
if (removeThemeBtn && themeSelect) {
|
||||
removeThemeBtn.addEventListener("click", () => {
|
||||
const theme = themeSelect.value;
|
||||
if (!theme) return showToast("❌ No theme selected", "error");
|
||||
if (["modern", "classic"].includes(theme)) {
|
||||
showToast("❌ Cannot remove default theme", "error");
|
||||
return;
|
||||
}
|
||||
themeToDelete = theme;
|
||||
deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`;
|
||||
deleteThemeModal.style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
// Modal logic for theme deletion
|
||||
if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) {
|
||||
deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => {
|
||||
deleteThemeModal.style.display = "none";
|
||||
themeToDelete = null;
|
||||
};
|
||||
window.onclick = function(event) {
|
||||
if (event.target === deleteThemeModal) {
|
||||
deleteThemeModal.style.display = "none";
|
||||
themeToDelete = null;
|
||||
}
|
||||
};
|
||||
deleteThemeModalConfirm.onclick = async () => {
|
||||
if (!themeToDelete) return;
|
||||
const res = await fetch("/api/theme/remove", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ theme: themeToDelete })
|
||||
});
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
showToast("✅ Theme removed!", "success");
|
||||
// Refresh theme select
|
||||
fetch("/api/themes")
|
||||
.then(res => res.json())
|
||||
.then(themes => {
|
||||
themeSelect.innerHTML = "";
|
||||
themes.forEach(theme => {
|
||||
const option = document.createElement("option");
|
||||
option.value = theme;
|
||||
option.textContent = theme;
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
showToast(result.error || "❌ Error removing theme", "error");
|
||||
}
|
||||
deleteThemeModal.style.display = "none";
|
||||
themeToDelete = null;
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch theme list and populate select
|
||||
if (themeSelect) {
|
||||
fetch("/api/themes")
|
||||
.then(res => res.json())
|
||||
.then(themes => {
|
||||
themeSelect.innerHTML = "";
|
||||
themes.forEach(theme => {
|
||||
const option = document.createElement("option");
|
||||
option.value = theme;
|
||||
option.textContent = theme;
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
// Set selected value after loading config
|
||||
fetch("/api/site-info")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
themeSelect.value = data.build?.theme || "";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Load config from server and populate form
|
||||
if (form) {
|
||||
fetch("/api/site-info")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
ipParagraphs = Array.isArray(data.legals?.intellectual_property)
|
||||
? data.legals.intellectual_property
|
||||
: [];
|
||||
renderIpParagraphs();
|
||||
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
|
||||
renderMenuItems();
|
||||
form.elements["info.title"].value = data.info?.title || "";
|
||||
form.elements["info.subtitle"].value = data.info?.subtitle || "";
|
||||
form.elements["info.description"].value = data.info?.description || "";
|
||||
form.elements["info.canonical"].value = data.info?.canonical || "";
|
||||
form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
|
||||
form.elements["info.author"].value = data.info?.author || "";
|
||||
form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
|
||||
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
|
||||
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
|
||||
form.elements["footer.copyright"].value = data.footer?.copyright || "";
|
||||
form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
|
||||
if (themeSelect) {
|
||||
themeSelect.value = data.build?.theme || "";
|
||||
}
|
||||
form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
|
||||
form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
|
||||
form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
|
||||
// Build checkboxes
|
||||
if (convertImagesCheckbox) {
|
||||
convertImagesCheckbox.checked = !!data.build?.convert_images;
|
||||
}
|
||||
if (resizeImagesCheckbox) {
|
||||
resizeImagesCheckbox.checked = !!data.build?.resize_images;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add menu item
|
||||
if (addMenuBtn) {
|
||||
addMenuBtn.addEventListener("click", () => {
|
||||
menuItems.push({ label: "", href: "" });
|
||||
renderMenuItems();
|
||||
});
|
||||
}
|
||||
|
||||
// Remove menu item
|
||||
menuList.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("remove-menu-item")) {
|
||||
const idx = parseInt(e.target.getAttribute("data-idx"));
|
||||
menuItems.splice(idx, 1);
|
||||
renderMenuItems();
|
||||
}
|
||||
});
|
||||
|
||||
// Update menuItems on input change
|
||||
menuList.addEventListener("input", () => {
|
||||
updateMenuItemsFromInputs();
|
||||
});
|
||||
|
||||
// Add paragraph
|
||||
if (addIpBtn) {
|
||||
addIpBtn.addEventListener("click", () => {
|
||||
ipParagraphs.push({ paragraph: "" });
|
||||
renderIpParagraphs();
|
||||
});
|
||||
}
|
||||
|
||||
// Remove paragraph
|
||||
ipList.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("remove-ip-paragraph")) {
|
||||
const idx = parseInt(e.target.getAttribute("data-idx"));
|
||||
ipParagraphs.splice(idx, 1);
|
||||
renderIpParagraphs();
|
||||
}
|
||||
});
|
||||
|
||||
// Update ipParagraphs on input change
|
||||
ipList.addEventListener("input", () => {
|
||||
updateIpParagraphsFromInputs();
|
||||
});
|
||||
|
||||
// Save config to server
|
||||
if (form) {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
updateMenuItemsFromInputs();
|
||||
updateIpParagraphsFromInputs();
|
||||
|
||||
// Check if thumbnail is set before saving (uploaded or present in input)
|
||||
if (!thumbnailInput || !thumbnailInput.value) {
|
||||
showToast("❌ Thumbnail is required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const build = {
|
||||
theme: themeSelect ? themeSelect.value : "",
|
||||
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
|
||||
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
|
||||
};
|
||||
|
||||
const payload = {
|
||||
info: {
|
||||
title: form.elements["info.title"].value,
|
||||
subtitle: form.elements["info.subtitle"].value,
|
||||
description: form.elements["info.description"].value,
|
||||
canonical: form.elements["info.canonical"].value,
|
||||
keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
|
||||
author: form.elements["info.author"].value
|
||||
},
|
||||
social: {
|
||||
instagram_url: form.elements["social.instagram_url"].value,
|
||||
thumbnail: thumbnailInput ? thumbnailInput.value : ""
|
||||
},
|
||||
menu: {
|
||||
items: menuItems
|
||||
},
|
||||
footer: {
|
||||
copyright: form.elements["footer.copyright"].value,
|
||||
legal_label: form.elements["footer.legal_label"].value
|
||||
},
|
||||
build,
|
||||
legals: {
|
||||
hoster_name: form.elements["legals.hoster_name"].value,
|
||||
hoster_address: form.elements["legals.hoster_address"].value,
|
||||
hoster_contact: form.elements["legals.hoster_contact"].value,
|
||||
intellectual_property: ipParagraphs
|
||||
}
|
||||
};
|
||||
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");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
454
src/webui/js/theme-editor.js
Normal file
454
src/webui/js/theme-editor.js
Normal file
@ -0,0 +1,454 @@
|
||||
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 setupColorPicker(colorId, btnId, textId, initial) {
|
||||
const colorInput = document.getElementById(colorId);
|
||||
const colorBtn = document.getElementById(btnId);
|
||||
const textInput = document.getElementById(textId);
|
||||
|
||||
colorInput.value = initial;
|
||||
colorBtn.style.background = initial;
|
||||
textInput.value = initial.toUpperCase();
|
||||
|
||||
// Color input is positioned over the button and is clickable
|
||||
colorInput.addEventListener("input", () => {
|
||||
colorBtn.style.background = colorInput.value;
|
||||
textInput.value = colorInput.value.toUpperCase();
|
||||
});
|
||||
|
||||
textInput.addEventListener("input", () => {
|
||||
if (/^#[0-9A-F]{6}$/i.test(textInput.value)) {
|
||||
colorInput.value = textInput.value;
|
||||
colorBtn.style.background = textInput.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setFontDropdown(selectId, value, options) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
select.innerHTML = options.map(opt =>
|
||||
`<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 remove-btn" 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 class="font-name">${font}</span>
|
||||
<button type="button" class="remove-font-btn danger remove-btn" data-font="${font}">🗑️</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) {
|
||||
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1");
|
||||
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384");
|
||||
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0");
|
||||
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700");
|
||||
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161");
|
||||
setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff");
|
||||
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff");
|
||||
}
|
||||
|
||||
// Fonts
|
||||
function refreshFontDropdowns() {
|
||||
setFontDropdown("font-primary", document.getElementById("font-primary").value, [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
}
|
||||
if (themeYaml.fonts) {
|
||||
setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
|
||||
setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
|
||||
}
|
||||
|
||||
// Font upload logic
|
||||
const fontUploadInput = document.getElementById("font-upload");
|
||||
const chooseFontBtn = document.getElementById("choose-font-btn");
|
||||
const fontUploadStatus = document.getElementById("font-upload-status");
|
||||
const localFontsList = document.getElementById("local-fonts-list");
|
||||
|
||||
// Modal logic for font deletion
|
||||
const deleteFontModal = document.getElementById("delete-font-modal");
|
||||
const deleteFontModalClose = document.getElementById("delete-font-modal-close");
|
||||
const deleteFontModalConfirm = document.getElementById("delete-font-modal-confirm");
|
||||
const deleteFontModalCancel = document.getElementById("delete-font-modal-cancel");
|
||||
let fontToDelete = null;
|
||||
|
||||
function refreshLocalFonts() {
|
||||
renderLocalFonts(localFonts);
|
||||
refreshFontDropdowns();
|
||||
}
|
||||
|
||||
if (chooseFontBtn && fontUploadInput) {
|
||||
chooseFontBtn.addEventListener("click", () => fontUploadInput.click());
|
||||
}
|
||||
|
||||
if (fontUploadInput) {
|
||||
fontUploadInput.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (!["woff", "woff2"].includes(ext)) {
|
||||
showToast("Only .woff and .woff2 fonts are allowed.", "error");
|
||||
return;
|
||||
}
|
||||
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") {
|
||||
showToast("✅ Font uploaded!", "success");
|
||||
localFonts = await fetchLocalFonts(themeInfo.theme_name);
|
||||
refreshLocalFonts();
|
||||
} else {
|
||||
showToast("Error uploading font.", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove font button triggers modal
|
||||
if (localFontsList) {
|
||||
localFontsList.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("remove-font-btn")) {
|
||||
fontToDelete = e.target.dataset.font;
|
||||
document.getElementById("delete-font-modal-text").textContent =
|
||||
`Are you sure you want to remove the font "${fontToDelete}"?`;
|
||||
deleteFontModal.style.display = "flex";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Modal logic for font deletion
|
||||
if (deleteFontModal && deleteFontModalClose && deleteFontModalConfirm && deleteFontModalCancel) {
|
||||
deleteFontModalClose.onclick = deleteFontModalCancel.onclick = () => {
|
||||
deleteFontModal.style.display = "none";
|
||||
fontToDelete = null;
|
||||
};
|
||||
window.onclick = function(event) {
|
||||
if (event.target === deleteFontModal) {
|
||||
deleteFontModal.style.display = "none";
|
||||
fontToDelete = null;
|
||||
}
|
||||
};
|
||||
deleteFontModalConfirm.onclick = async () => {
|
||||
if (!fontToDelete) return;
|
||||
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 ? "block" : "none";
|
||||
}
|
||||
if (removeFaviconBtn) {
|
||||
removeFaviconBtn.style.display = src ? "block" : "none";
|
||||
}
|
||||
if (chooseFaviconBtn) {
|
||||
chooseFaviconBtn.style.display = src ? "none" : "block";
|
||||
}
|
||||
}
|
||||
|
||||
if (chooseFaviconBtn && faviconUpload) {
|
||||
chooseFaviconBtn.addEventListener("click", () => faviconUpload.click());
|
||||
}
|
||||
|
||||
if (faviconUpload) {
|
||||
faviconUpload.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const ext = file.name.split('.').pop().toLowerCase();
|
||||
if (!["png", "jpg", "jpeg", "ico"].includes(ext)) {
|
||||
showToast("Invalid file type for favicon.", "error");
|
||||
return;
|
||||
}
|
||||
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", async () => {
|
||||
googleFonts.push({ family: "", weights: [] });
|
||||
// Save immediately to backend
|
||||
await fetch("/api/theme-google-fonts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
|
||||
});
|
||||
// Fetch updated theme info and refresh dropdowns
|
||||
const updatedThemeInfo = await fetchThemeInfo();
|
||||
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
|
||||
googleFonts.length = 0;
|
||||
googleFonts.push(...updatedGoogleFonts);
|
||||
renderGoogleFonts(googleFonts);
|
||||
refreshFontDropdowns();
|
||||
});
|
||||
}
|
||||
|
||||
const googleFontsFields = document.getElementById("google-fonts-fields");
|
||||
if (googleFontsFields) {
|
||||
// Save on blur for family/weights fields
|
||||
googleFontsFields.addEventListener("blur", async (e) => {
|
||||
if (
|
||||
e.target.name &&
|
||||
(e.target.name.endsWith("[family]") || e.target.name.endsWith("[weights]"))
|
||||
) {
|
||||
// Update googleFonts array from the form fields
|
||||
const fontFields = googleFontsFields.querySelectorAll(".input-field");
|
||||
googleFonts.length = 0;
|
||||
fontFields.forEach(field => {
|
||||
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value.trim();
|
||||
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
|
||||
.split(",").map(w => w.trim()).filter(Boolean);
|
||||
googleFonts.push({ family, weights });
|
||||
});
|
||||
// Save immediately to backend
|
||||
await fetch("/api/theme-google-fonts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
|
||||
});
|
||||
// Fetch updated theme info and refresh dropdowns
|
||||
const updatedThemeInfo = await fetchThemeInfo();
|
||||
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
|
||||
googleFonts.length = 0;
|
||||
googleFonts.push(...updatedGoogleFonts);
|
||||
renderGoogleFonts(googleFonts);
|
||||
refreshFontDropdowns();
|
||||
}
|
||||
}, true); // Use capture phase to catch blur from children
|
||||
|
||||
// Delegate remove button click for Google Fonts
|
||||
googleFontsFields.addEventListener("click", async (e) => {
|
||||
if (e.target.classList.contains("remove-google-font")) {
|
||||
const idx = Number(e.target.dataset.idx);
|
||||
googleFonts.splice(idx, 1);
|
||||
// Save immediately to backend
|
||||
await fetch("/api/theme-google-fonts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
|
||||
});
|
||||
// Fetch updated theme info and refresh dropdowns
|
||||
const updatedThemeInfo = await fetchThemeInfo();
|
||||
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
|
||||
googleFonts.length = 0;
|
||||
googleFonts.push(...updatedGoogleFonts);
|
||||
renderGoogleFonts(googleFonts);
|
||||
refreshFontDropdowns();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form submit
|
||||
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {};
|
||||
data.colors = {
|
||||
primary: document.getElementById("color-primary-text").value,
|
||||
primary_dark: document.getElementById("color-primary-dark-text").value,
|
||||
secondary: document.getElementById("color-secondary-text").value,
|
||||
accent: document.getElementById("color-accent-text").value,
|
||||
text_dark: document.getElementById("color-text-dark-text").value,
|
||||
background: document.getElementById("color-background-text").value,
|
||||
browser_color: document.getElementById("color-browser-color-text").value
|
||||
};
|
||||
data.fonts = {
|
||||
primary: {
|
||||
name: document.getElementById("font-primary").value,
|
||||
fallback: document.getElementById("font-primary-fallback").value
|
||||
},
|
||||
secondary: {
|
||||
name: document.getElementById("font-secondary").value,
|
||||
fallback: document.getElementById("font-secondary-fallback").value
|
||||
}
|
||||
};
|
||||
data.favicon = {
|
||||
path: faviconInput.value
|
||||
};
|
||||
data.google_fonts = [];
|
||||
document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => {
|
||||
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value;
|
||||
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
|
||||
.split(",").map(w => w.trim()).filter(w => w);
|
||||
if (family) data.google_fonts.push({ family, weights });
|
||||
});
|
||||
|
||||
const res = await fetch("/api/theme-info", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data })
|
||||
});
|
||||
if (res.ok) {
|
||||
showToast("✅ Theme saved!", "success");
|
||||
} else {
|
||||
showToast("Error saving theme.", "error");
|
||||
}
|
||||
});
|
||||
});
|
41
src/webui/js/upload.js
Normal file
41
src/webui/js/upload.js
Normal file
@ -0,0 +1,41 @@
|
||||
// --- Upload gallery images ---
|
||||
document.getElementById('upload-gallery').addEventListener('change', async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append('files', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
|
||||
refreshGallery();
|
||||
} else showToast('Error: ' + data.error, "error");
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showToast('Server error!', "error");
|
||||
} finally { e.target.value = ''; }
|
||||
});
|
||||
|
||||
// --- Upload hero images ---
|
||||
document.getElementById('upload-hero').addEventListener('change', async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append('files', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
|
||||
refreshHero();
|
||||
} else showToast('Error: ' + data.error, "error");
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
showToast('Server error!', "error");
|
||||
} finally { e.target.value = ''; }
|
||||
});
|
234
src/webui/site-info/index.html
Normal file
234
src/webui/site-info/index.html
Normal file
@ -0,0 +1,234 @@
|
||||
<!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">
|
||||
<input type="checkbox" id="nav-check">
|
||||
<div class="nav-header">
|
||||
<div class="nav-title">
|
||||
<a href="/"><img src="{{ url_for('static', filename='img/logo.svg') }}"></a>
|
||||
</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"><a href="/gallery-editor">Gallery</a></li>
|
||||
<li class="nav-item"><a href="/site-info">Site info</a></li>
|
||||
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
|
||||
<li class="nav-item">
|
||||
<button id="build-btn" class="button">🚀 Build !</button>
|
||||
</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>
|
||||
<p>Set the basic information for your site and SEO</p>
|
||||
<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>
|
||||
<p>Set your social media links and thumbnail for link sharing</p>
|
||||
<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;">
|
||||
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
|
||||
<div class="thumbnail-form">
|
||||
<input type="hidden" name="social.thumbnail" id="social-thumbnail">
|
||||
<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>
|
||||
<p>Manage your site menu items. You can use tag combination to propose custom filters</p>
|
||||
<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>
|
||||
<p>Set your copyright informations and legal link name</p>
|
||||
<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>
|
||||
<p>Set your legal informations</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
<label>Hoster Name</label>
|
||||
<input type="text" name="legals.hoster_name" placeholder="Name" required>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Hoster Address</label>
|
||||
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Hoster Contact</label>
|
||||
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
|
||||
</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>
|
||||
<p>Select a theme from the dropdown menu or add your custom theme folder</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
<label>Theme</label>
|
||||
<select name="build.theme" id="theme-select" required></select>
|
||||
<button type="button" id="remove-theme-btn" class="remove-btn up-btn danger">🗑 Remove selected theme</button>
|
||||
<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>
|
||||
<p>If checked, images will be converted for web and resized to fit the theme</p>
|
||||
<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 class="section">
|
||||
<h2>Steps</h2>
|
||||
<p> Follow the steps to generate your static gallery</p>
|
||||
<ul id="stepper">
|
||||
<li><a href="/gallery-editor">Upload your photos</a></li>
|
||||
<div>→</div>
|
||||
<li><a class="step-active" href="/site-info">Configure site info</a></li>
|
||||
<div>→</div>
|
||||
<li><a href="/theme-editor">Customize your theme</a></li>
|
||||
<div>→</div>
|
||||
<li><button id="stepper-build">Generate your static site!</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete confirmation modal (now outside .content-inner) -->
|
||||
<div id="delete-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="delete-modal-close" class="modal-close">×</span>
|
||||
<h3>Confirm Deletion</h3>
|
||||
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
|
||||
<div class="modal-actions">
|
||||
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
|
||||
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete theme confirmation modal -->
|
||||
<div id="delete-theme-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="delete-theme-modal-close" class="modal-close">×</span>
|
||||
<h3>Confirm Theme Deletion</h3>
|
||||
<p id="delete-theme-modal-text">Are you sure you want to remove this theme?</p>
|
||||
<div class="modal-actions">
|
||||
<button id="delete-theme-modal-confirm" class="modal-btn danger">Remove</button>
|
||||
<button id="delete-theme-modal-cancel" class="modal-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Build success modal -->
|
||||
<div id="build-success-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="build-success-modal-close" class="modal-close">×</span>
|
||||
<h3>✅ Build completed!</h3>
|
||||
<p>Your files are available in the output folder.</p>
|
||||
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
|
||||
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer">
|
||||
<div class="content-inner">
|
||||
<div class="footer-container">
|
||||
<div class="footer-credit">
|
||||
<p><a href="https//lumeex.djeex.fr"><span class="lum-first">Lum</span><span class="lum-second">eex</span> v{{ lumeex_version }}</a> — © 2025</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
|
||||
<a class="footer-link gitea" href="https://gitea.com/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
|
||||
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/site-info.js')}}"></script>
|
||||
<script src="{{ url_for('static', filename='js/build.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
890
src/webui/style/style.css
Normal file
890
src/webui/style/style.css
Normal file
@ -0,0 +1,890 @@
|
||||
/* --- 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;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin:0px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #d3d3d3;
|
||||
}
|
||||
|
||||
.content-inner {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
max-width: 1140px;
|
||||
padding: 0 40px;
|
||||
padding-top: 70px;
|
||||
}
|
||||
|
||||
|
||||
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 --- */
|
||||
.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;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
|
||||
.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 {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
background-color: #0c0d0c29;
|
||||
z-index: 1000;
|
||||
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-btn label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.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: #55c3ec;
|
||||
}
|
||||
|
||||
.nav > .nav-links > .nav-list > .nav-item > a:active {
|
||||
color: #55c3ec;
|
||||
}
|
||||
|
||||
.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-bar .button {
|
||||
padding: 10px 25px;
|
||||
border-radius: 40px;
|
||||
margin: 15px 20px 15px 10px;
|
||||
font-size: 14px;
|
||||
display: inline;
|
||||
background: linear-gradient(135deg, #26c4ff, #016074);
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-bar .button:hover {
|
||||
background: linear-gradient(135deg, #72d9ff, #26657e);
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-links > ul {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-btn span {
|
||||
display: block;
|
||||
height: 4px;
|
||||
width: 28px;
|
||||
background: #fff;
|
||||
margin: 4px 0;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
/* --- Custom Upload Buttons --- */
|
||||
.up-btn, .footer-links a{
|
||||
display: inline-block;
|
||||
background: #00000000;
|
||||
color: #fff;
|
||||
padding: 0.5em 1em;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
transition: all 0.1s ease;
|
||||
user-select: none;
|
||||
/* box-shadow: 0 4px 10px rgba(0,0,0,0.25);*/
|
||||
font-size: 14px;
|
||||
border: 1px solid #585858;
|
||||
}
|
||||
|
||||
.up-btn:hover, .footer-links a:hover {
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
/* --- Modal Styles --- */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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 --- */
|
||||
|
||||
|
||||
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: #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: 0 0 45px 0;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(38,196,255,0.15);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
#site-info-form button[type="submit"]:hover, #theme-editor-form button[type="submit"]:hover {
|
||||
background: linear-gradient(135deg, #72d9ff, #26657e);
|
||||
}
|
||||
|
||||
#site-info-form button[type="button"], #theme-editor-form button[type="button"] {
|
||||
background: #00000000;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 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;
|
||||
}
|
||||
|
||||
#favicon-preview {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
.color-fields {
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
#theme-editor button.color-btn {
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
input[type="color"].color-input {
|
||||
margin:0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
height:100%;
|
||||
position:absolute;
|
||||
left:0;
|
||||
top:0;
|
||||
opacity:0;
|
||||
}
|
||||
|
||||
fieldset p, .section p {
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
color: #b3b3b3;
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.font-name {
|
||||
background: #1f2223;
|
||||
color: #b3b3b3;
|
||||
border: 1px solid #585858;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 4px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.07);
|
||||
}
|
||||
|
||||
#theme-editor-form button[type="button"]#choose-font-btn {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* --- Home --- */
|
||||
|
||||
#stepper {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch; /* Ensures all children match tallest height */
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
#stepper li,
|
||||
#stepper > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
#stepper li a, #stepper li button, #stepper > div {
|
||||
justify-content: center;
|
||||
min-width: 100px;
|
||||
border: 1px solid #585858;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background: #111010;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#stepper li a, #stepper li button {
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#stepper li button#stepper-build {
|
||||
background: linear-gradient(135deg, #26c4ff, #016074);
|
||||
transition: all 0.2s ease;
|
||||
font-weight: bold;
|
||||
color:#fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
#stepper li button#stepper-build:hover {
|
||||
background: linear-gradient(135deg, #72d9ff, #26657e);
|
||||
transition: all 0.2s ease;
|
||||
color:#fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#stepper li a:hover, #stepper li a.step-active {
|
||||
color: #fff;
|
||||
transition: all 0.2s ease;
|
||||
background: #277fa0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#stepper li button#stepper-build::before {
|
||||
content: "🚀 ";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#stepper li a {
|
||||
text-decoration: none;
|
||||
color:#d3d3d3
|
||||
}
|
||||
|
||||
#stepper li {
|
||||
flex: 1 1 auto; /* li can grow/shrink, width is flexible */
|
||||
}
|
||||
|
||||
#stepper > div {
|
||||
flex: 0 0 auto; /* Do not grow or shrink, width is auto */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffc700;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
min-width: 0; /* Prevent unwanted minimum width */
|
||||
width: auto; /* Let width fit content */
|
||||
}
|
||||
|
||||
#footer {
|
||||
background-color: #0c0d0c29;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid #21212157;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
|
||||
.footer-links, .footer-links a {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lum-first {
|
||||
font-weight: bold;
|
||||
}
|
||||
.lum-second {
|
||||
color: #55c3ec;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#footer a {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.footer-credit .lum-first::before {
|
||||
content: url(/img/favicon.svg);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#footer .content-inner {
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
display: flex;
|
||||
}
|
||||
|
239
src/webui/theme-editor/index.html
Normal file
239
src/webui/theme-editor/index.html
Normal file
@ -0,0 +1,239 @@
|
||||
<!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">
|
||||
<input type="checkbox" id="nav-check">
|
||||
<div class="nav-header">
|
||||
<div class="nav-title">
|
||||
<a href="/"><img src="{{ url_for('static', filename='img/logo.svg') }}"></a>
|
||||
</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"><a href="/gallery-editor">Gallery</a></li>
|
||||
<li class="nav-item"><a href="/site-info">Site info</a></li>
|
||||
<li class="nav-item"><a href="/theme-editor">Theme info</a></li>
|
||||
<li class="nav-item">
|
||||
<button id="build-btn" class="button">🚀 Build !</button>
|
||||
</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>
|
||||
<p>Set the color values for your theme</p>
|
||||
<div class="fields">
|
||||
<!-- Example for one color field, repeat for all -->
|
||||
<div class="input-field">
|
||||
<label>Primary</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-primary-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.primary" id="color-primary" class="color-input">
|
||||
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Primary Dark</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input">
|
||||
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Secondary</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-secondary-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.secondary" id="color-secondary" class="color-input">
|
||||
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Accent</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-accent-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.accent" id="color-accent" class="color-input">
|
||||
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Text Dark</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input">
|
||||
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Background</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-background-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.background" id="color-background" class="color-input">
|
||||
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Browser Color</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-browser-color-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input">
|
||||
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<!-- Google Fonts Section -->
|
||||
<fieldset>
|
||||
<h2>Google Fonts</h2>
|
||||
<p>Add Google Fonts to your theme</p>
|
||||
<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</h2>
|
||||
<p>Supported formats: .woff, .woff2</p>
|
||||
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
|
||||
<div id="local-fonts-list" class="font-list"></div>
|
||||
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
|
||||
</fieldset>
|
||||
<!-- Fonts Section -->
|
||||
<fieldset>
|
||||
<h2>Fonts</h2>
|
||||
<p>Select where to apply your fonts</p>
|
||||
<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>
|
||||
<p>Supported formats: .png, .jpg, .jpeg</p>
|
||||
<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 class="section">
|
||||
<h2>Steps</h2>
|
||||
<p> Follow the steps to generate your static gallery</p>
|
||||
<ul id="stepper">
|
||||
<li><a href="/gallery-editor">Upload your photos</a></li>
|
||||
<div>→</div>
|
||||
<li><a href="/site-info">Configure site info</a></li>
|
||||
<div>→</div>
|
||||
<li><a class="step-active" href="/theme-editor">Customize your theme</a></li>
|
||||
<div>→</div>
|
||||
<li><button id="stepper-build">Generate your static site!</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete confirmation modal for favicon -->
|
||||
<div id="delete-favicon-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="delete-favicon-modal-close" class="modal-close">×</span>
|
||||
<h3>Confirm Deletion</h3>
|
||||
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
|
||||
<div class="modal-actions">
|
||||
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
|
||||
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete confirmation modal for font -->
|
||||
<div id="delete-font-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="delete-font-modal-close" class="modal-close">×</span>
|
||||
<h3>Confirm Deletion</h3>
|
||||
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
|
||||
<div class="modal-actions">
|
||||
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
|
||||
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Build success modal -->
|
||||
<div id="build-success-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
<span id="build-success-modal-close" class="modal-close">×</span>
|
||||
<h3>✅ Build completed!</h3>
|
||||
<p>Your files are available in the output folder.</p>
|
||||
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
|
||||
<div id="zip-loader" style="display:none;">Creating ZIP...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="footer">
|
||||
<div class="content-inner">
|
||||
<div class="footer-container">
|
||||
<div class="footer-credit">
|
||||
<p><a href="https//lumeex.djeex.fr"><span class="lum-first">Lum</span><span class="lum-second">eex</span> v{{ lumeex_version }}</a> — © 2025</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
|
||||
<a class="footer-link gitea" href="https://gitea.com/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
|
||||
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/build.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user