Compare commits
37 Commits
330e467dcb
...
v2.0.1
Author | SHA1 | Date | |
---|---|---|---|
df96782500 | |||
febf4d2be5 | |||
617545e1bb | |||
922ce99679 | |||
cd0428990a | |||
d3af86be8c | |||
c825798b13 | |||
b5375343a8 | |||
757e676d2d | |||
0079c166e8 | |||
ee6d4a1fa2 | |||
c6c3162b83 | |||
04c1214cd1 | |||
b03779b487 | |||
b5f8ceeb31 | |||
1591886505 | |||
a6b63c2d2b | |||
8a04fe5aa6 | |||
2cb171806c | |||
ded97700d9 | |||
8533ce72e9 | |||
b2ba1d7c7f | |||
5d238fcf33 | |||
7675b90909 | |||
a916c80c2a | |||
cb91b92555 | |||
f6e6a11fc1 | |||
e9a3a5a189 | |||
4ac176f8a9 | |||
1ea94b469b | |||
2ec4be624b | |||
369704a87c | |||
7a95ef0255 | |||
906699f023 | |||
643a729f94 | |||
a02da47e73 | |||
f7f2356510 |
@ -6,9 +6,8 @@ COPY requirements.txt .
|
|||||||
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY build.py gallery.py VERSION /app/
|
||||||
COPY ./src/ ./src/
|
COPY ./src/ ./src/
|
||||||
COPY ./build.py ./build.py
|
|
||||||
COPY ./gallery.py ./gallery.py
|
|
||||||
COPY ./config /app/default
|
COPY ./config /app/default
|
||||||
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
||||||
RUN chmod +x /app/entrypoint.sh
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
22
README.MD
22
README.MD
@ -18,7 +18,7 @@ The project includes two thoughtfully designed themes—one modern, one minimali
|
|||||||
- **Typewriter** — [View Demo](https://typewriter.djeex.fr)
|
- **Typewriter** — [View Demo](https://typewriter.djeex.fr)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> This GitHub repository is a mirror of the primary source at [git.djeex.fr/Djeex/lumeex](https://git.djeex.fr/Djeex/lumeex). The main repository includes the full history, releases, and bug-checking assisted by an LLM.
|
> _This GitHub repository is a mirror of the primary source at [git.djeex.fr/Djeex/lumeex](https://git.djeex.fr/Djeex/lumeex). The main repository includes the full history and releases_.
|
||||||
|
|
||||||
|
|
||||||
## 📌 Table of Contents
|
## 📌 Table of Contents
|
||||||
@ -41,20 +41,26 @@ The project includes two thoughtfully designed themes—one modern, one minimali
|
|||||||
- Typewriter — [Demo](https://typewriter.djeex.fr)
|
- Typewriter — [Demo](https://typewriter.djeex.fr)
|
||||||
- Supports Google Fonts and locally hosted fonts
|
- Supports Google Fonts and locally hosted fonts
|
||||||
|
|
||||||
### No-Code Builder (YAML Based)
|
### No-Code Builder (WebUI Manager)
|
||||||
|
|
||||||
- Configure site info, SEO, colors, fonts, and more through simple YAML files
|
|
||||||
- Reference and tag photos without any coding required
|
<div align="center">
|
||||||
- *(Optional)* Automatically update photo references via script
|
<img src="https://git.djeex.fr/Djeex/lumeex/raw/branch/main/illustration/lumeex-webui.png" alt="Lumeex Screenshot" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
### Simple Build Process
|
|
||||||
|
|
||||||
- Compiles static site from YAML configuration files (themes, templates, fonts, colors)
|
- Configure site info, SEO, colors, fonts, and more through a simple convenient WebUI
|
||||||
|
- Add and tag your photo photos without any coding required
|
||||||
- Converts favicon automatically to all required formats
|
- Converts favicon automatically to all required formats
|
||||||
- Resizes social sharing thumbnails
|
- Resizes social sharing thumbnails
|
||||||
- *(Optional)* Automatically resizes photos to a maximum width of 1140px
|
- *(Optional)* Automatically resizes photos to a maximum width of 1140px
|
||||||
- *(Optional)* Converts images to WebP format for optimized performance
|
- *(Optional)* Converts images to WebP format for optimized performance
|
||||||
- Outputs a complete static website ready to deploy on any web server
|
- Build your static site in one click and get a zip archive or an output folder, ready to deploy to your preferred webserver
|
||||||
|
|
||||||
|
### Don't want a WebUI ?
|
||||||
|
|
||||||
|
- CLI process is documented
|
||||||
|
|
||||||
## 🐳 Docker or 🐍 Python Installation
|
## 🐳 Docker or 🐍 Python Installation
|
||||||
For comprehensive documentation on installation, configuration options, customization, and demos, please visit:
|
For comprehensive documentation on installation, configuration options, customization, and demos, please visit:
|
||||||
|
@ -8,8 +8,8 @@ colors:
|
|||||||
secondary: '#00b0f0'
|
secondary: '#00b0f0'
|
||||||
accent: '#ffc700'
|
accent: '#ffc700'
|
||||||
text_dark: '#616161'
|
text_dark: '#616161'
|
||||||
background: '#fff'
|
background: '#ffffff'
|
||||||
browser_color: '#fff'
|
browser_color: '#ffffff'
|
||||||
favicon:
|
favicon:
|
||||||
path: favicon.png
|
path: favicon.png
|
||||||
google_fonts:
|
google_fonts:
|
||||||
|
@ -8,8 +8,8 @@ colors:
|
|||||||
secondary: '#00b0f0'
|
secondary: '#00b0f0'
|
||||||
accent: '#ffc700'
|
accent: '#ffc700'
|
||||||
text_dark: '#616161'
|
text_dark: '#616161'
|
||||||
background: '#fff'
|
background: '#ffffff'
|
||||||
browser_color: '#fff'
|
browser_color: '#ffffff'
|
||||||
favicon:
|
favicon:
|
||||||
path: favicon.png
|
path: favicon.png
|
||||||
fonts:
|
fonts:
|
||||||
|
@ -43,16 +43,24 @@ start_server() {
|
|||||||
cat /tmp/build_logs_fifo >&2 &
|
cat /tmp/build_logs_fifo >&2 &
|
||||||
cat /tmp/build_logs_fifo2 >&2 &
|
cat /tmp/build_logs_fifo2 >&2 &
|
||||||
|
|
||||||
echo "Starting HTTP server on port 3000..."
|
echo "Starting preview HTTP server on port 3000..."
|
||||||
python3 -u -m http.server 3000 -d /app/output &
|
python3 -u -m http.server 3000 -d /app/output &
|
||||||
SERVER_PID=$!
|
SERVER_PID=$!
|
||||||
trap "echo 'Stopping server...'; kill -TERM $SERVER_PID 2>/dev/null; wait $SERVER_PID; exit 0" SIGINT SIGTERM
|
|
||||||
|
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 $SERVER_PID
|
||||||
|
wait $WEBUI_PID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
VERSION=$(cat VERSION)
|
||||||
if [ $# -eq 0 ]; then
|
if [ $# -eq 0 ]; then
|
||||||
echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}"
|
echo -e "${CYAN}╭───────────────────────────────────────────╮${NC}"
|
||||||
echo -e "${CYAN}│${NC} Lum${CYAN}eex${NC} - Version 1.3.1${NC} ${CYAN}│${NC}"
|
echo -e "${CYAN}│${NC} Lum${CYAN}eex${NC} - Version ${VERSION}${NC} ${CYAN}│${NC}"
|
||||||
echo -e "${CYAN}├───────────────────────────────────────────┤${NC}"
|
echo -e "${CYAN}├───────────────────────────────────────────┤${NC}"
|
||||||
echo -e "${CYAN}│${NC} Source: https://git.djeex.fr/Djeex/lumeex ${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} Mirror: https://github.com/Djeex/lumeex ${CYAN}│${NC}"
|
||||||
|
@ -7,4 +7,5 @@ services:
|
|||||||
- ../output:/app/output # mount output directory
|
- ../output:/app/output # mount output directory
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
|
- "5000:5000"
|
||||||
|
|
BIN
illustration/lumeex-webui.png
Normal file
BIN
illustration/lumeex-webui.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 620 KiB |
@ -3,6 +3,13 @@
|
|||||||
|
|
||||||
// Fade in effect for elements with class 'appear'
|
// Fade in effect for elements with class 'appear'
|
||||||
const setupIntersectionObserver = () => {
|
const setupIntersectionObserver = () => {
|
||||||
|
document.querySelectorAll('.appear').forEach(parent => {
|
||||||
|
const children = parent.querySelectorAll('.appear');
|
||||||
|
children.forEach((child, i) => {
|
||||||
|
child.style.transitionDelay = `${i * 0.2}s`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const items = document.querySelectorAll('.appear');
|
const items = document.querySelectorAll('.appear');
|
||||||
const io = new IntersectionObserver((entries) => {
|
const io = new IntersectionObserver((entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
@ -32,6 +39,7 @@ const randomizeHeroBackground = () => {
|
|||||||
if (images.length === 0) return;
|
if (images.length === 0) return;
|
||||||
let currentIndex = Math.floor(Math.random() * images.length);
|
let currentIndex = Math.floor(Math.random() * images.length);
|
||||||
heroBg.style.backgroundImage = `url(/img/${images[currentIndex]})`;
|
heroBg.style.backgroundImage = `url(/img/${images[currentIndex]})`;
|
||||||
|
if (images.length < 2) return; // <-- Prevent interval if only one image
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
let nextIndex;
|
let nextIndex;
|
||||||
do {
|
do {
|
||||||
|
@ -192,36 +192,6 @@ h2 {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.appear.inview:nth-child(1) {
|
|
||||||
-webkit-transition-delay: 0s;
|
|
||||||
transition-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appear.inview:nth-child(2) {
|
|
||||||
-webkit-transition-delay: 0.2s;
|
|
||||||
transition-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appear.inview:nth-child(3) {
|
|
||||||
-webkit-transition-delay: 0.4s;
|
|
||||||
transition-delay: 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appear.inview:nth-child(4) {
|
|
||||||
-webkit-transition-delay: 0.6s;
|
|
||||||
transition-delay: 0.6s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appear.inview:nth-child(5) {
|
|
||||||
-webkit-transition-delay: 0.8s;
|
|
||||||
transition-delay: 0.8s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.appear.inview:nth-child(6) {
|
|
||||||
-webkit-transition-delay: 1s;
|
|
||||||
transition-delay: 1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* img fade in */
|
/* img fade in */
|
||||||
|
|
||||||
.fade-in-img {
|
.fade-in-img {
|
||||||
@ -491,7 +461,7 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.section img {
|
.section img {
|
||||||
margin: 0px 0 60px 0;
|
margin: 0px 0 40px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag {
|
.tag {
|
||||||
|
@ -21,12 +21,14 @@ STYLE_DIR = SRC_DIR / "src/public/style"
|
|||||||
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
|
GALLERY_FILE = SRC_DIR / "config/gallery.yaml"
|
||||||
SITE_FILE = SRC_DIR / "config/site.yaml"
|
SITE_FILE = SRC_DIR / "config/site.yaml"
|
||||||
THEMES_DIR = SRC_DIR / "config/themes"
|
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():
|
def build():
|
||||||
build_version = "v1.3.1"
|
|
||||||
logging.info("\n")
|
logging.info("\n")
|
||||||
logging.info("=" * 24)
|
logging.info("=" * 24)
|
||||||
logging.info(f"🚀 Lumeex builder {build_version}")
|
logging.info(f"🚀 Lumeex builder v{build_version}")
|
||||||
logging.info("=" * 24)
|
logging.info("=" * 24)
|
||||||
logging.info("\n === Starting build === ")
|
logging.info("\n === Starting build === ")
|
||||||
ensure_dir(BUILD_DIR)
|
ensure_dir(BUILD_DIR)
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
|
# --- Imports ---
|
||||||
import logging
|
import logging
|
||||||
import yaml
|
import yaml
|
||||||
|
import subprocess
|
||||||
|
import zipfile
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Flask, jsonify, request, send_from_directory, render_template
|
from flask import (
|
||||||
|
Flask, jsonify, request, send_from_directory, render_template,
|
||||||
|
send_file, after_this_request
|
||||||
|
)
|
||||||
from src.py.builder.gallery_builder import (
|
from src.py.builder.gallery_builder import (
|
||||||
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
|
GALLERY_YAML, load_yaml, save_yaml, update_gallery, update_hero
|
||||||
)
|
)
|
||||||
@ -11,6 +18,10 @@ from src.py.webui.upload import upload_bp
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
|
||||||
# --- Flask app setup ---
|
# --- 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
|
WEBUI_PATH = Path(__file__).parents[2] / "webui" # Path to static/templates
|
||||||
app = Flask(
|
app = Flask(
|
||||||
__name__,
|
__name__,
|
||||||
@ -19,37 +30,73 @@ app = Flask(
|
|||||||
static_url_path=""
|
static_url_path=""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Config paths ---
|
||||||
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||||
|
|
||||||
# --- Photos directory (configurable) ---
|
|
||||||
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
|
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||||
app.config["PHOTOS_DIR"] = PHOTOS_DIR
|
app.config["PHOTOS_DIR"] = PHOTOS_DIR
|
||||||
|
|
||||||
# --- Register upload blueprint ---
|
# --- Register upload blueprint ---
|
||||||
app.register_blueprint(upload_bp)
|
app.register_blueprint(upload_bp)
|
||||||
|
|
||||||
# --- Routes ---
|
# --- 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("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
"""Serve the main HTML page."""
|
|
||||||
return render_template("index.html")
|
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"])
|
@app.route("/api/gallery", methods=["GET"])
|
||||||
def get_gallery():
|
def get_gallery():
|
||||||
"""Return JSON list of gallery images from YAML."""
|
"""Get gallery images."""
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
return jsonify(data.get("gallery", {}).get("images", []))
|
return jsonify(data.get("gallery", {}).get("images", []))
|
||||||
|
|
||||||
@app.route("/api/hero", methods=["GET"])
|
@app.route("/api/hero", methods=["GET"])
|
||||||
def get_hero():
|
def get_hero():
|
||||||
"""Return JSON list of hero images from YAML."""
|
"""Get hero images."""
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
return jsonify(data.get("hero", {}).get("images", []))
|
return jsonify(data.get("hero", {}).get("images", []))
|
||||||
|
|
||||||
@app.route("/api/gallery/update", methods=["POST"])
|
@app.route("/api/gallery/update", methods=["POST"])
|
||||||
def update_gallery_api():
|
def update_gallery_api():
|
||||||
"""Update gallery images in YAML from frontend JSON."""
|
"""Update gallery images."""
|
||||||
images = request.json
|
images = request.json
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
data["gallery"]["images"] = images
|
data["gallery"]["images"] = images
|
||||||
@ -58,7 +105,7 @@ def update_gallery_api():
|
|||||||
|
|
||||||
@app.route("/api/hero/update", methods=["POST"])
|
@app.route("/api/hero/update", methods=["POST"])
|
||||||
def update_hero_api():
|
def update_hero_api():
|
||||||
"""Update hero images in YAML from frontend JSON."""
|
"""Update hero images."""
|
||||||
images = request.json
|
images = request.json
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
data["hero"]["images"] = images
|
data["hero"]["images"] = images
|
||||||
@ -67,49 +114,48 @@ def update_hero_api():
|
|||||||
|
|
||||||
@app.route("/api/gallery/refresh", methods=["POST"])
|
@app.route("/api/gallery/refresh", methods=["POST"])
|
||||||
def refresh_gallery():
|
def refresh_gallery():
|
||||||
"""Refresh gallery YAML from photos/gallery folder."""
|
"""Refresh gallery images from disk."""
|
||||||
update_gallery()
|
update_gallery()
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
@app.route("/api/hero/refresh", methods=["POST"])
|
@app.route("/api/hero/refresh", methods=["POST"])
|
||||||
def refresh_hero():
|
def refresh_hero():
|
||||||
"""Refresh hero YAML from photos/hero folder."""
|
"""Refresh hero images from disk."""
|
||||||
update_hero()
|
update_hero()
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
# --- Gallery & Hero photo deletion ---
|
||||||
@app.route("/api/gallery/delete", methods=["POST"])
|
@app.route("/api/gallery/delete", methods=["POST"])
|
||||||
def delete_gallery_photo():
|
def delete_gallery_photo():
|
||||||
"""Delete a gallery photo from disk and return status."""
|
"""Delete a gallery photo."""
|
||||||
data = request.json
|
data = request.json
|
||||||
src = data.get("src")
|
src = data.get("src")
|
||||||
file_path = PHOTOS_DIR / "gallery" / src
|
file_path = PHOTOS_DIR / "gallery" / src
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
return {"error": "File not found"}, 404
|
return {"error": "❌ File not found"}, 404
|
||||||
|
|
||||||
@app.route("/api/hero/delete", methods=["POST"])
|
@app.route("/api/hero/delete", methods=["POST"])
|
||||||
def delete_hero_photo():
|
def delete_hero_photo():
|
||||||
"""Delete a hero photo from disk and return status."""
|
"""Delete a hero photo."""
|
||||||
data = request.json
|
data = request.json
|
||||||
src = data.get("src")
|
src = data.get("src")
|
||||||
file_path = PHOTOS_DIR / "hero" / src
|
file_path = PHOTOS_DIR / "hero" / src
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
return {"error": "File not found"}, 404
|
return {"error": "❌ File not found"}, 404
|
||||||
|
|
||||||
@app.route("/api/gallery/delete_all", methods=["POST"])
|
@app.route("/api/gallery/delete_all", methods=["POST"])
|
||||||
def delete_all_gallery_photos():
|
def delete_all_gallery_photos():
|
||||||
"""Delete all gallery photos from disk and YAML."""
|
"""Delete all gallery photos."""
|
||||||
gallery_dir = PHOTOS_DIR / "gallery"
|
gallery_dir = PHOTOS_DIR / "gallery"
|
||||||
deleted = 0
|
deleted = 0
|
||||||
# Remove all files in gallery folder
|
|
||||||
for file in gallery_dir.glob("*"):
|
for file in gallery_dir.glob("*"):
|
||||||
if file.is_file():
|
if file.is_file():
|
||||||
file.unlink()
|
file.unlink()
|
||||||
deleted += 1
|
deleted += 1
|
||||||
# Clear YAML gallery images
|
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
data["gallery"]["images"] = []
|
data["gallery"]["images"] = []
|
||||||
save_yaml(data, GALLERY_YAML)
|
save_yaml(data, GALLERY_YAML)
|
||||||
@ -117,63 +163,69 @@ def delete_all_gallery_photos():
|
|||||||
|
|
||||||
@app.route("/api/hero/delete_all", methods=["POST"])
|
@app.route("/api/hero/delete_all", methods=["POST"])
|
||||||
def delete_all_hero_photos():
|
def delete_all_hero_photos():
|
||||||
"""Delete all hero photos from disk and YAML."""
|
"""Delete all hero photos."""
|
||||||
hero_dir = PHOTOS_DIR / "hero"
|
hero_dir = PHOTOS_DIR / "hero"
|
||||||
deleted = 0
|
deleted = 0
|
||||||
# Remove all files in hero folder
|
|
||||||
for file in hero_dir.glob("*"):
|
for file in hero_dir.glob("*"):
|
||||||
if file.is_file():
|
if file.is_file():
|
||||||
file.unlink()
|
file.unlink()
|
||||||
deleted += 1
|
deleted += 1
|
||||||
# Clear YAML hero images
|
|
||||||
data = load_yaml(GALLERY_YAML)
|
data = load_yaml(GALLERY_YAML)
|
||||||
data["hero"]["images"] = []
|
data["hero"]["images"] = []
|
||||||
save_yaml(data, GALLERY_YAML)
|
save_yaml(data, GALLERY_YAML)
|
||||||
return jsonify({"status": "ok", "deleted": deleted})
|
return jsonify({"status": "ok", "deleted": deleted})
|
||||||
|
|
||||||
|
# --- Serve photos ---
|
||||||
@app.route("/photos/<section>/<path:filename>")
|
@app.route("/photos/<section>/<path:filename>")
|
||||||
def photos(section, filename):
|
def photos(section, filename):
|
||||||
"""Serve uploaded photos from disk."""
|
"""Serve a photo from a section."""
|
||||||
return send_from_directory(PHOTOS_DIR / section, filename)
|
return send_from_directory(PHOTOS_DIR / section, filename)
|
||||||
|
|
||||||
@app.route("/photos/<path:filename>")
|
@app.route("/photos/<path:filename>")
|
||||||
def serve_photo(filename):
|
def serve_photo(filename):
|
||||||
|
"""Serve a photo from the photos directory."""
|
||||||
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
|
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||||
return send_from_directory(photos_dir, filename)
|
return send_from_directory(photos_dir, filename)
|
||||||
|
|
||||||
|
# --- Site info page & API ---
|
||||||
@app.route("/site-info")
|
@app.route("/site-info")
|
||||||
def site_info():
|
def site_info():
|
||||||
|
"""Render site info editor page."""
|
||||||
return render_template("site-info/index.html")
|
return render_template("site-info/index.html")
|
||||||
|
|
||||||
@app.route("/api/site-info", methods=["GET"])
|
@app.route("/api/site-info", methods=["GET"])
|
||||||
def get_site_info():
|
def get_site_info():
|
||||||
|
"""Get site info YAML as JSON."""
|
||||||
with open(SITE_YAML, "r") as f:
|
with open(SITE_YAML, "r") as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
@app.route("/api/site-info", methods=["POST"])
|
@app.route("/api/site-info", methods=["POST"])
|
||||||
def update_site_info():
|
def update_site_info():
|
||||||
|
"""Update site info YAML."""
|
||||||
data = request.json
|
data = request.json
|
||||||
with open(SITE_YAML, "w") as f:
|
with open(SITE_YAML, "w") as f:
|
||||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
# --- Theme management ---
|
||||||
@app.route("/api/themes")
|
@app.route("/api/themes")
|
||||||
def list_themes():
|
def list_themes():
|
||||||
|
"""List available themes."""
|
||||||
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
|
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
|
||||||
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
|
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
|
||||||
return jsonify(themes)
|
return jsonify(themes)
|
||||||
|
|
||||||
|
# --- Thumbnail upload/remove ---
|
||||||
@app.route("/api/thumbnail/upload", methods=["POST"])
|
@app.route("/api/thumbnail/upload", methods=["POST"])
|
||||||
def upload_thumbnail():
|
def upload_thumbnail():
|
||||||
|
"""Upload thumbnail image and update site.yaml."""
|
||||||
PHOTOS_DIR = app.config["PHOTOS_DIR"]
|
PHOTOS_DIR = app.config["PHOTOS_DIR"]
|
||||||
file = request.files.get("file")
|
file = request.files.get("file")
|
||||||
if not file:
|
if not file:
|
||||||
return {"error": "No file provided"}, 400
|
return {"error": "❌ No file provided"}, 400
|
||||||
filename = "thumbnail.png"
|
filename = "thumbnail.png"
|
||||||
file.save(PHOTOS_DIR / filename)
|
file.save(PHOTOS_DIR / filename)
|
||||||
# Update site.yaml
|
|
||||||
with open(SITE_YAML, "r") as f:
|
with open(SITE_YAML, "r") as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
data.setdefault("social", {})["thumbnail"] = filename
|
data.setdefault("social", {})["thumbnail"] = filename
|
||||||
@ -183,12 +235,11 @@ def upload_thumbnail():
|
|||||||
|
|
||||||
@app.route("/api/thumbnail/remove", methods=["POST"])
|
@app.route("/api/thumbnail/remove", methods=["POST"])
|
||||||
def remove_thumbnail():
|
def remove_thumbnail():
|
||||||
|
"""Remove thumbnail image and update site.yaml."""
|
||||||
PHOTOS_DIR = app.config["PHOTOS_DIR"]
|
PHOTOS_DIR = app.config["PHOTOS_DIR"]
|
||||||
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
|
thumbnail_path = PHOTOS_DIR / "thumbnail.png"
|
||||||
# Remove thumbnail file if exists
|
|
||||||
if thumbnail_path.exists():
|
if thumbnail_path.exists():
|
||||||
thumbnail_path.unlink()
|
thumbnail_path.unlink()
|
||||||
# Update site.yaml to remove thumbnail key
|
|
||||||
with open(SITE_YAML, "r") as f:
|
with open(SITE_YAML, "r") as f:
|
||||||
data = yaml.safe_load(f)
|
data = yaml.safe_load(f)
|
||||||
if "social" in data and "thumbnail" in data["social"]:
|
if "social" in data and "thumbnail" in data["social"]:
|
||||||
@ -197,13 +248,14 @@ def remove_thumbnail():
|
|||||||
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
# --- Theme upload ---
|
||||||
@app.route("/api/theme/upload", methods=["POST"])
|
@app.route("/api/theme/upload", methods=["POST"])
|
||||||
def upload_theme():
|
def upload_theme():
|
||||||
|
"""Upload a custom theme folder."""
|
||||||
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
|
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
|
||||||
files = request.files.getlist("files")
|
files = request.files.getlist("files")
|
||||||
if not files:
|
if not files:
|
||||||
return jsonify({"error": "No files provided"}), 400
|
return jsonify({"error": "❌ No files provided"}), 400
|
||||||
# Get folder name from first file's webkitRelativePath
|
|
||||||
first_path = files[0].filename
|
first_path = files[0].filename
|
||||||
folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
|
folder_name = first_path.split("/")[0] if "/" in first_path else "custom"
|
||||||
theme_folder = themes_dir / folder_name
|
theme_folder = themes_dir / folder_name
|
||||||
@ -215,7 +267,225 @@ def upload_theme():
|
|||||||
file.save(dest_path)
|
file.save(dest_path)
|
||||||
return jsonify({"status": "ok", "theme": folder_name})
|
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 ---
|
# --- Run server ---
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
logging.info("Starting WebUI at http://127.0.0.1:5000")
|
logging.info("Starting WebUI at http://0.0.0.0:5000")
|
||||||
app.run(debug=True)
|
app.run(host="0.0.0.0", port=5000, debug=True)
|
@ -12,7 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="inner bottom-link appear">
|
<div class="inner bottom-link appear">
|
||||||
<p><span class="navigation-subtitle appear">{{ copyright }}</span><span class="nav-separator"> • </span><span class="navigation-bottom-link appear"><a href="{{ legal_link }}">{{ legal_label }}</a></span></p>
|
<p><span class="navigation-subtitle appear">{{ copyright }}</span><span class="nav-separator"> • </span><span class="navigation-bottom-link appear"><a href="{{ legal_link }}">{{ legal_label }}</a></span></p>
|
||||||
<p class="navigation-subtitle appear"> Built with <a href="https://git.djeex.fr/Djeex/lumeex">Lumeex</a></p>
|
<p class="navigation-subtitle appear"> Built with <a href="https://lumeex.djeex.fr">Lumeex</a></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
70
src/webui/gallery-editor/index.html
Normal file
70
src/webui/gallery-editor/index.html
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
|
||||||
|
{% extends "template/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Lumeex - Gallery Editor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/gallery-editor.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
|
||||||
|
{% endblock %}
|
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 |
@ -1,90 +1,31 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "template/base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{% block title %}Lumeex{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta charset="UTF-8">
|
{% block content %}
|
||||||
<title>Lumeex</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
|
<h1>Static Gallery Generator</h1>
|
||||||
</head>
|
<p>Use this generator to create a static gallery from your photos. Then, upload the static files to your preferred web server.</p>
|
||||||
<body>
|
|
||||||
<!-- Top bar -->
|
<!-- Hero Upload Section -->
|
||||||
<div class="nav-bar">
|
<div class="section">
|
||||||
<div class="content-inner nav">
|
<h2>Steps</h2>
|
||||||
<div class="nav-cta">
|
<p> Follow the steps to generate your static gallery</p>
|
||||||
<div class="arrow">→</div>
|
<div class="stepper">
|
||||||
<a class="button" href="#" target="_blank">
|
<ul id="stepper">
|
||||||
<span id="step">🚀 Build !<i class="fa-solid fa-envelope"></i></span>
|
<li><a href="/gallery-editor">Upload your photos</a></li>
|
||||||
</a>
|
<div></div>
|
||||||
</div>
|
<li><a href="/site-info">Configure site info</a></li>
|
||||||
<input type="checkbox" id="nav-check">
|
<div></div>
|
||||||
<div class="nav-header">
|
<li><a href="/theme-editor">Customize your theme</a></li>
|
||||||
<div class="nav-title">
|
<div></div>
|
||||||
<img src="{{ url_for('static', filename='img/logo.svg') }}">
|
<li><button id="stepper-build">Generate your static site!</button></li>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="nav-btn">
|
|
||||||
<label for="nav-check">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="nav-links">
|
|
||||||
<ul class="nav-list">
|
|
||||||
<li class="nav-item appear2"><a href="/site-info">Site info</a>
|
|
||||||
<li class="nav-item appear2"><a href="#">Theme info</a>
|
|
||||||
<li class="nav-item appear2"><a href="#">Gallery</a>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<!-- Toast container for notifications -->
|
|
||||||
<div class="content-inner">
|
|
||||||
<div id="toast-container"></div>
|
|
||||||
|
|
||||||
<h1>Gallery editor</h1>
|
{% endblock %}
|
||||||
|
|
||||||
<!-- Hero Upload Section -->
|
{% block scripts %}
|
||||||
<div class="upload-section">
|
|
||||||
<h2>Title Carrousel</h2>
|
|
||||||
<p> Select photos to display in the Title Carrousel</p>
|
|
||||||
<div class="upload-actions-row">
|
|
||||||
<label for="upload-hero" class="up-btn">
|
|
||||||
📸 Upload photos
|
|
||||||
</label>
|
|
||||||
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
|
|
||||||
</div>
|
|
||||||
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
|
||||||
<div id="hero"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gallery Upload Section -->
|
{% endblock %}
|
||||||
<div class="upload-section">
|
|
||||||
<h2>Gallery</h2>
|
|
||||||
<p> Select and tags photos to display in the Gallery</p>
|
|
||||||
<div class="upload-actions-row">
|
|
||||||
<label for="upload-gallery" class="up-btn">
|
|
||||||
📸 Upload photos
|
|
||||||
</label>
|
|
||||||
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
|
|
||||||
</div>
|
|
||||||
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
|
||||||
<div id="gallery"></div>
|
|
||||||
</div>
|
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}" defer></script>
|
|
||||||
<script src="{{ url_for('static', filename='js/upload.js') }}" defer></script>
|
|
||||||
</div>
|
|
||||||
<!-- Delete confirmation modal -->
|
|
||||||
<div id="delete-modal" class="modal" style="display:none;">
|
|
||||||
<div class="modal-content">
|
|
||||||
<span id="delete-modal-close" class="modal-close">×</span>
|
|
||||||
<h3>Confirm Deletion</h3>
|
|
||||||
<p id="delete-modal-text">Are you sure you want to delete this image?</p>
|
|
||||||
<div class="modal-actions">
|
|
||||||
<button id="delete-modal-confirm" class="modal-btn danger">Delete</button>
|
|
||||||
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
106
src/webui/js/build.js
Normal file
106
src/webui/js/build.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Show a toast notification.
|
||||||
|
* @param {string} message - The message to display.
|
||||||
|
* @param {string} type - "success" or "error".
|
||||||
|
* @param {number} duration - Duration in ms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Loader helpers ---
|
||||||
|
function showLoader(text = "Uploading...") {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) {
|
||||||
|
loader.classList.add("active");
|
||||||
|
document.getElementById("loader-text").textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideLoader() {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) loader.classList.remove("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Toast helpers ---
|
||||||
|
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() {
|
||||||
|
showLoader("Building static site...");
|
||||||
|
// Trigger build on backend
|
||||||
|
const res = await fetch("/api/build", { method: "POST" });
|
||||||
|
const result = await res.json();
|
||||||
|
hideLoader();
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
@ -92,6 +92,14 @@ function renderTags(imgIndex, tags) {
|
|||||||
input.placeholder = 'Add tag...';
|
input.placeholder = 'Add tag...';
|
||||||
inputContainer.appendChild(input);
|
inputContainer.appendChild(input);
|
||||||
|
|
||||||
|
// --- Validate button ---
|
||||||
|
const validateBtn = document.createElement('button');
|
||||||
|
validateBtn.textContent = '✔️';
|
||||||
|
validateBtn.className = 'validate-tag-btn';
|
||||||
|
validateBtn.style.display = 'none'; // hidden by default
|
||||||
|
validateBtn.style.marginLeft = '4px';
|
||||||
|
inputContainer.appendChild(validateBtn);
|
||||||
|
|
||||||
const suggestionBox = document.createElement('ul');
|
const suggestionBox = document.createElement('ul');
|
||||||
suggestionBox.className = 'suggestions';
|
suggestionBox.className = 'suggestions';
|
||||||
inputContainer.appendChild(suggestionBox);
|
inputContainer.appendChild(suggestionBox);
|
||||||
@ -148,8 +156,14 @@ function renderTags(imgIndex, tags) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
input.addEventListener('input', updateSuggestions);
|
input.addEventListener('input', () => {
|
||||||
input.addEventListener('focus', updateSuggestions);
|
updateSuggestions();
|
||||||
|
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
|
||||||
|
});
|
||||||
|
input.addEventListener('focus', () => {
|
||||||
|
updateSuggestions();
|
||||||
|
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
input.addEventListener('keydown', (e) => {
|
input.addEventListener('keydown', (e) => {
|
||||||
const items = suggestionBox.querySelectorAll('li');
|
const items = suggestionBox.querySelectorAll('li');
|
||||||
@ -172,11 +186,13 @@ function renderTags(imgIndex, tags) {
|
|||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
updateSuggestions();
|
updateSuggestions();
|
||||||
|
validateBtn.style.display = 'none';
|
||||||
} else if ([' ', ','].includes(e.key)) {
|
} else if ([' ', ','].includes(e.key)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
addTag(input.value);
|
addTag(input.value);
|
||||||
input.value = '';
|
input.value = '';
|
||||||
updateSuggestions();
|
updateSuggestions();
|
||||||
|
validateBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -184,9 +200,20 @@ function renderTags(imgIndex, tags) {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
suggestionBox.style.display = 'none';
|
suggestionBox.style.display = 'none';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
validateBtn.style.display = 'none';
|
||||||
}, 150);
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Validate button action ---
|
||||||
|
validateBtn.onclick = () => {
|
||||||
|
if (input.value.trim()) {
|
||||||
|
addTag(input.value.trim());
|
||||||
|
input.value = '';
|
||||||
|
updateSuggestions();
|
||||||
|
validateBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
input.focus();
|
input.focus();
|
||||||
updateSuggestions();
|
updateSuggestions();
|
||||||
}
|
}
|
@ -12,6 +12,19 @@ function showToast(message, type = "success", duration = 3000) {
|
|||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Loader helpers ---
|
||||||
|
function showLoader(text = "Uploading...") {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) {
|
||||||
|
loader.classList.add("active");
|
||||||
|
document.getElementById("loader-text").textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideLoader() {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) loader.classList.remove("active");
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
// Form and menu logic
|
// Form and menu logic
|
||||||
const form = document.getElementById("site-info-form");
|
const form = document.getElementById("site-info-form");
|
||||||
@ -29,8 +42,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
div.style.gap = "8px";
|
div.style.gap = "8px";
|
||||||
div.style.marginBottom = "6px";
|
div.style.marginBottom = "6px";
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label">
|
<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">
|
<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>
|
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
|
||||||
`;
|
`;
|
||||||
menuList.appendChild(div);
|
menuList.appendChild(div);
|
||||||
@ -63,7 +76,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
div.style.gap = "8px";
|
div.style.gap = "8px";
|
||||||
div.style.marginBottom = "6px";
|
div.style.marginBottom = "6px";
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<textarea placeholder="Paragraph" style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
|
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
|
||||||
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
|
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
|
||||||
`;
|
`;
|
||||||
ipList.appendChild(div);
|
ipList.appendChild(div);
|
||||||
@ -98,6 +111,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
const deleteModalConfirm = document.getElementById("delete-modal-confirm");
|
const deleteModalConfirm = document.getElementById("delete-modal-confirm");
|
||||||
const deleteModalCancel = document.getElementById("delete-modal-cancel");
|
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
|
// Show/hide thumbnail preview, remove button, and choose button
|
||||||
function updateThumbnailPreview(src) {
|
function updateThumbnailPreview(src) {
|
||||||
if (thumbnailPreview) {
|
if (thumbnailPreview) {
|
||||||
@ -122,16 +143,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
thumbnailUpload.addEventListener("change", async (e) => {
|
thumbnailUpload.addEventListener("change", async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
showLoader("Uploading thumbnail...");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
const res = await fetch("/api/thumbnail/upload", { method: "POST", body: formData });
|
const res = await fetch("/api/thumbnail/upload", { method: "POST", body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
|
hideLoader();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
if (thumbnailInput) thumbnailInput.value = result.filename;
|
if (thumbnailInput) thumbnailInput.value = result.filename;
|
||||||
updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`);
|
updateThumbnailPreview(`/photos/${result.filename}?t=${Date.now()}`);
|
||||||
showToast("Thumbnail uploaded!", "success");
|
showToast("✅ Thumbnail uploaded!", "success");
|
||||||
} else {
|
} else {
|
||||||
showToast("Error uploading thumbnail", "error");
|
showToast("❌ Error uploading thumbnail", "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -159,9 +182,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
if (thumbnailInput) thumbnailInput.value = "";
|
if (thumbnailInput) thumbnailInput.value = "";
|
||||||
updateThumbnailPreview("");
|
updateThumbnailPreview("");
|
||||||
showToast("Thumbnail removed!", "success");
|
showToast("✅ Thumbnail removed!", "success");
|
||||||
} else {
|
} else {
|
||||||
showToast("Error removing thumbnail", "error");
|
showToast("❌ Error removing thumbnail", "error");
|
||||||
}
|
}
|
||||||
deleteModal.style.display = "none";
|
deleteModal.style.display = "none";
|
||||||
};
|
};
|
||||||
@ -175,14 +198,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
themeUpload.addEventListener("change", async (e) => {
|
themeUpload.addEventListener("change", async (e) => {
|
||||||
const files = Array.from(e.target.files);
|
const files = Array.from(e.target.files);
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
showLoader("Uploading theme...");
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
formData.append("files", file, file.webkitRelativePath || file.name);
|
formData.append("files", file, file.webkitRelativePath || file.name);
|
||||||
});
|
});
|
||||||
const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
|
const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
|
||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
|
hideLoader();
|
||||||
if (result.status === "ok") {
|
if (result.status === "ok") {
|
||||||
showToast("Theme uploaded!", "success");
|
showToast("✅ Theme uploaded!", "success");
|
||||||
// Refresh theme select after upload
|
// Refresh theme select after upload
|
||||||
fetch("/api/themes")
|
fetch("/api/themes")
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@ -196,11 +221,71 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
showToast("Error uploading theme", "error");
|
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;
|
||||||
|
showLoader("Removing theme...");
|
||||||
|
const res = await fetch("/api/theme/remove", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ theme: themeToDelete })
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
hideLoader();
|
||||||
|
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
|
// Fetch theme list and populate select
|
||||||
if (themeSelect) {
|
if (themeSelect) {
|
||||||
fetch("/api/themes")
|
fetch("/api/themes")
|
||||||
@ -311,6 +396,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
updateMenuItemsFromInputs();
|
updateMenuItemsFromInputs();
|
||||||
updateIpParagraphsFromInputs();
|
updateIpParagraphsFromInputs();
|
||||||
|
|
||||||
|
// Check if thumbnail is set before saving (uploaded or present in input)
|
||||||
|
if (!thumbnailInput || !thumbnailInput.value) {
|
||||||
|
showLoader("Saving...");
|
||||||
|
showToast("❌ Thumbnail is required.", "error");
|
||||||
|
hideLoader();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const build = {
|
const build = {
|
||||||
theme: themeSelect ? themeSelect.value : "",
|
theme: themeSelect ? themeSelect.value : "",
|
||||||
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
|
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
|
||||||
@ -345,6 +438,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
intellectual_property: ipParagraphs
|
intellectual_property: ipParagraphs
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// --- REMOVE loader for save ---
|
||||||
|
// showLoader("Saving...");
|
||||||
const res = await fetch("/api/site-info", {
|
const res = await fetch("/api/site-info", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
467
src/webui/js/theme-editor.js
Normal file
467
src/webui/js/theme-editor.js
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Loader helpers ---
|
||||||
|
function showLoader(text = "Uploading...") {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) {
|
||||||
|
loader.classList.add("active");
|
||||||
|
document.getElementById("loader-text").textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideLoader() {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) loader.classList.remove("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Color Picker
|
||||||
|
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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
showLoader("Uploading font...");
|
||||||
|
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();
|
||||||
|
hideLoader();
|
||||||
|
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;
|
||||||
|
showLoader("Removing font...");
|
||||||
|
const result = await removeFont(themeInfo.theme_name, fontToDelete);
|
||||||
|
hideLoader();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
showLoader("Uploading favicon...");
|
||||||
|
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();
|
||||||
|
hideLoader();
|
||||||
|
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 () => {
|
||||||
|
showLoader("Removing favicon...");
|
||||||
|
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();
|
||||||
|
hideLoader();
|
||||||
|
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: [] });
|
||||||
|
await fetch("/api/theme-google-fonts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
|
||||||
|
});
|
||||||
|
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) {
|
||||||
|
googleFontsFields.addEventListener("blur", async (e) => {
|
||||||
|
if (
|
||||||
|
e.target.name &&
|
||||||
|
(e.target.name.endsWith("[family]") || e.target.name.endsWith("[weights]"))
|
||||||
|
) {
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
await fetch("/api/theme-google-fonts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
|
||||||
|
});
|
||||||
|
const updatedThemeInfo = await fetchThemeInfo();
|
||||||
|
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
|
||||||
|
googleFonts.length = 0;
|
||||||
|
googleFonts.push(...updatedGoogleFonts);
|
||||||
|
renderGoogleFonts(googleFonts);
|
||||||
|
refreshFontDropdowns();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
googleFontsFields.addEventListener("click", async (e) => {
|
||||||
|
if (e.target.classList.contains("remove-google-font")) {
|
||||||
|
const idx = Number(e.target.dataset.idx);
|
||||||
|
googleFonts.splice(idx, 1);
|
||||||
|
await fetch("/api/theme-google-fonts", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ theme_name: themeInfo.theme_name, google_fonts: googleFonts })
|
||||||
|
});
|
||||||
|
const updatedThemeInfo = await fetchThemeInfo();
|
||||||
|
const updatedGoogleFonts = updatedThemeInfo.theme_yaml.google_fonts || [];
|
||||||
|
googleFonts.length = 0;
|
||||||
|
googleFonts.push(...updatedGoogleFonts);
|
||||||
|
renderGoogleFonts(googleFonts);
|
||||||
|
refreshFontDropdowns();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
showLoader("Saving theme...");
|
||||||
|
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 })
|
||||||
|
});
|
||||||
|
hideLoader();
|
||||||
|
if (res.ok) {
|
||||||
|
showToast("✅ Theme saved!", "success");
|
||||||
|
} else {
|
||||||
|
showToast("Error saving theme.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
@ -1,41 +1,64 @@
|
|||||||
|
// --- Loader helpers ---
|
||||||
|
function showLoader(text = "Uploading...") {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) {
|
||||||
|
loader.classList.add("active");
|
||||||
|
document.getElementById("loader-text").textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hideLoader() {
|
||||||
|
const loader = document.getElementById("global-loader");
|
||||||
|
if (loader) loader.classList.remove("active");
|
||||||
|
}
|
||||||
|
|
||||||
// --- Upload gallery images ---
|
// --- Upload gallery images ---
|
||||||
document.getElementById('upload-gallery').addEventListener('change', async (e) => {
|
const galleryInput = document.getElementById('upload-gallery');
|
||||||
const files = e.target.files;
|
if (galleryInput) {
|
||||||
if (!files.length) return;
|
galleryInput.addEventListener('change', async (e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
showLoader("Uploading photos...");
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) formData.append('files', file);
|
||||||
|
|
||||||
const formData = new FormData();
|
try {
|
||||||
for (const file of files) formData.append('files', file);
|
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
try {
|
hideLoader();
|
||||||
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
|
if (res.ok) {
|
||||||
const data = await res.json();
|
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
|
||||||
if (res.ok) {
|
if (typeof refreshGallery === "function") refreshGallery();
|
||||||
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
|
} else showToast('Error: ' + data.error, "error");
|
||||||
refreshGallery();
|
} catch(err) {
|
||||||
} else showToast('Error: ' + data.error, "error");
|
hideLoader();
|
||||||
} catch(err) {
|
console.error(err);
|
||||||
console.error(err);
|
showToast('Server error!', "error");
|
||||||
showToast('Server error!', "error");
|
} finally { e.target.value = ''; }
|
||||||
} finally { e.target.value = ''; }
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// --- Upload hero images ---
|
// --- Upload hero images ---
|
||||||
document.getElementById('upload-hero').addEventListener('change', async (e) => {
|
const heroInput = document.getElementById('upload-hero');
|
||||||
const files = e.target.files;
|
if (heroInput) {
|
||||||
if (!files.length) return;
|
heroInput.addEventListener('change', async (e) => {
|
||||||
|
const files = e.target.files;
|
||||||
|
if (!files.length) return;
|
||||||
|
showLoader("Uploading hero photos...");
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of files) formData.append('files', file);
|
||||||
|
|
||||||
const formData = new FormData();
|
try {
|
||||||
for (const file of files) formData.append('files', file);
|
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
|
||||||
|
const data = await res.json();
|
||||||
try {
|
hideLoader();
|
||||||
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
|
if (res.ok) {
|
||||||
const data = await res.json();
|
showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
|
||||||
if (res.ok) {
|
if (typeof refreshHero === "function") refreshHero();
|
||||||
showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
|
} else showToast('Error: ' + data.error, "error");
|
||||||
refreshHero();
|
} catch(err) {
|
||||||
} else showToast('Error: ' + data.error, "error");
|
hideLoader();
|
||||||
} catch(err) {
|
console.error(err);
|
||||||
console.error(err);
|
showToast('Server error!', "error");
|
||||||
showToast('Server error!', "error");
|
} finally { e.target.value = ''; }
|
||||||
} finally { e.target.value = ''; }
|
});
|
||||||
});
|
}
|
@ -1,89 +1,55 @@
|
|||||||
<!DOCTYPE html>
|
{% extends "template/base.html" %}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
{% block title %}Lumeex - Site Info{% endblock %}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta charset="UTF-8">
|
{% block content %}
|
||||||
<title>Lumeex</title>
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<!-- Top bar -->
|
|
||||||
<div class="nav-bar">
|
|
||||||
<div class="content-inner nav">
|
|
||||||
<div class="nav-cta">
|
|
||||||
<div class="arrow">→</div>
|
|
||||||
<a class="button" href="#" target="_blank">
|
|
||||||
<span id="step">🚀 Build !<i class="fa-solid fa-envelope"></i></span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" id="nav-check">
|
|
||||||
<div class="nav-header">
|
|
||||||
<div class="nav-title">
|
|
||||||
<img src="{{ url_for('static', filename='img/logo.svg') }}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="nav-btn">
|
|
||||||
<label for="nav-check">
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
<span></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="nav-links">
|
|
||||||
<ul class="nav-list">
|
|
||||||
<li class="nav-item appear2"><a href="/site-info">Site info</a></li>
|
|
||||||
<li class="nav-item appear2"><a href="#">Theme info</a></li>
|
|
||||||
<li class="nav-item appear2"><a href="#">Gallery</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Toast container for notifications -->
|
|
||||||
<div id="site-info" class="content-inner">
|
|
||||||
<div id="toast-container"></div>
|
|
||||||
<h1>Edit Site Info</h1>
|
<h1>Edit Site Info</h1>
|
||||||
<form id="site-info-form">
|
<form id="site-info-form">
|
||||||
<!-- Info Section -->
|
<!-- Info Section -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Info</legend>
|
<h2>Info</h2>
|
||||||
|
<p>Set the basic information for your site and SEO</p>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Title</label>
|
<label>Title</label>
|
||||||
<input type="text" name="info.title" placeholder="Your site title">
|
<input type="text" name="info.title" placeholder="Your site title" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Subtitle</label>
|
<label>Subtitle</label>
|
||||||
<input type="text" name="info.subtitle" placeholder="Your site subtitle">
|
<input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Description</label>
|
<label>Description</label>
|
||||||
<input type="text" name="info.description" placeholder="Your site description">
|
<input type="text" name="info.description" placeholder="Your site description" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Canonical URL</label>
|
<label>Canonical URL</label>
|
||||||
<input type="text" name="info.canonical" placeholder="https://yoursite.com">
|
<input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Keywords (comma separated)</label>
|
<label>Keywords (comma separated)</label>
|
||||||
<input type="text" name="info.keywords" placeholder="photo, gallery, photography">
|
<input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Author</label>
|
<label>Author</label>
|
||||||
<input type="text" name="info.author" placeholder="Your Name">
|
<input type="text" name="info.author" placeholder="Your Name" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<!-- Social Section -->
|
<!-- Social Section -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Social</legend>
|
<h2>Social</h2>
|
||||||
|
<p>Set your social media links and thumbnail for link sharing</p>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Instagram URL</label>
|
<label>Instagram URL</label>
|
||||||
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile">
|
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
|
||||||
<label class="thumbnail-form-label">Thumbnail</label>
|
<label class="thumbnail-form-label">Thumbnail</label>
|
||||||
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
<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>
|
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
|
||||||
<div class="thumbnail-form">
|
<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;">
|
<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>
|
<button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
@ -92,7 +58,8 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<!-- Menu Section -->
|
<!-- Menu Section -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Menu</legend>
|
<h2>Menu</h2>
|
||||||
|
<p>Manage your site menu items. You can use tag combination to propose custom filters</p>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="input-field" style="flex: 1 1 100%;">
|
<div class="input-field" style="flex: 1 1 100%;">
|
||||||
<div id="menu-items-list"></div>
|
<div id="menu-items-list"></div>
|
||||||
@ -102,33 +69,35 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<!-- Footer Section -->
|
<!-- Footer Section -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Footer</legend>
|
<h2>Footer</h2>
|
||||||
|
<p>Set your copyright informations and legal link name</p>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Copyright</label>
|
<label>Copyright</label>
|
||||||
<input type="text" name="footer.copyright">
|
<input type="text" name="footer.copyright" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Legal Label</label>
|
<label>Legal Label</label>
|
||||||
<input type="text" name="footer.legal_label">
|
<input type="text" name="footer.legal_label" re>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<!-- Legals Section -->
|
<!-- Legals Section -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Legals</legend>
|
<h2>Legals</h2>
|
||||||
|
<p>Set your legal informations</p>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Hoster Name</label>
|
<label>Hoster Name</label>
|
||||||
<input type="text" name="legals.hoster_name" placeholder="Name">
|
<input type="text" name="legals.hoster_name" placeholder="Name" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Hoster Address</label>
|
<label>Hoster Address</label>
|
||||||
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country">
|
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Hoster Contact</label>
|
<label>Hoster Contact</label>
|
||||||
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone">
|
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-field" style="flex: 1 1 100%;">
|
<div class="input-field" style="flex: 1 1 100%;">
|
||||||
<label>Intellectual Property</label>
|
<label>Intellectual Property</label>
|
||||||
@ -139,14 +108,17 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<!-- Build Section -->
|
<!-- Build Section -->
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>Build</legend>
|
<h2>Build</h2>
|
||||||
|
<p>Select a theme from the dropdown menu or add your custom theme folder</p>
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<div class="input-field">
|
<div class="input-field">
|
||||||
<label>Theme</label>
|
<label>Theme</label>
|
||||||
<select name="build.theme" id="theme-select"></select>
|
<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;">
|
<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>
|
<button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
|
||||||
<label class="thumbnail-form-label">Images processing</label>
|
<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>
|
<label>
|
||||||
<input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
|
<input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
|
||||||
Convert images
|
Convert images
|
||||||
@ -160,19 +132,51 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
<!-- Delete confirmation modal (now outside .content-inner) -->
|
<!-- Delete thumbnail confirmation modal-->
|
||||||
<div id="delete-modal" class="modal" style="display:none;">
|
<div class="content-inner">
|
||||||
<div class="modal-content">
|
<div id="delete-modal" class="modal" style="display:none;">
|
||||||
<span id="delete-modal-close" class="modal-close">×</span>
|
<div class="modal-content">
|
||||||
<h3>Confirm Deletion</h3>
|
<span id="delete-modal-close" class="modal-close">×</span>
|
||||||
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
|
<h3>Confirm Deletion</h3>
|
||||||
<div class="modal-actions">
|
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
|
||||||
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
|
<div class="modal-actions">
|
||||||
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
|
||||||
|
<button id="delete-modal-cancel" class="modal-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ url_for('static', filename='js/site-info.js')}}"></script>
|
<!-- Delete theme confirmation modal -->
|
||||||
</body>
|
<div class="content-inner">
|
||||||
</html>
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/site-info.js') }}"></script>
|
||||||
|
{% endblock %}
|
File diff suppressed because it is too large
Load Diff
86
src/webui/template/base.html
Normal file
86
src/webui/template/base.html
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% block title %}Lumeex{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
|
||||||
|
<meta name="darkreader-lock">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="nav-bar">
|
||||||
|
<div class="content-inner nav">
|
||||||
|
<div class="nav-header">
|
||||||
|
<a href="/" class="nav-title">
|
||||||
|
<img src="{{ url_for('static', filename='img/logo.svg') }}">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Burger toggle input and label -->
|
||||||
|
<input type="checkbox" id="nav-toggle" class="nav-toggle" hidden>
|
||||||
|
<label for="nav-toggle" class="nav-burger">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</label>
|
||||||
|
<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 class="inner">
|
||||||
|
<div id="toast-container"></div>
|
||||||
|
<!-- Page content -->
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
<!-- Build success modal -->
|
||||||
|
<div class="content-inner">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Footer -->
|
||||||
|
<div id="footer">
|
||||||
|
<div class="content-inner">
|
||||||
|
<div class="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>
|
||||||
|
</div>
|
||||||
|
<!-- Loader -->
|
||||||
|
<div id="global-loader">
|
||||||
|
<div class="loader-inner">
|
||||||
|
<div class="loader-spinner"></div>
|
||||||
|
<div id="loader-text">Uploading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="{{ url_for('static', filename='js/build.js') }}" defer></script>
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
182
src/webui/theme-editor/index.html
Normal file
182
src/webui/theme-editor/index.html
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
{% extends "template/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Lumeex - Theme Editor{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<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 id="color-picker">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script>
|
||||||
|
{% endblock %}
|
Reference in New Issue
Block a user