45 Commits
Author SHA1 Message Date
Djeex fc5b1c4234 Merge pull request 'Hotfix - HDR artefacts and ICC issues' (#27) from dev into main
Reviewed-on: #27
2026-08-17 15:27:39 +02:00
Djeex 2968e875a7 Add Lumeex-web link to README.MD 2026-08-17 15:20:06 +02:00
Djeex a7ce49b989 v2.1.2 2026-08-17 15:13:36 +02:00
Djeex 26e3ca684b Fixed: Preserve embedded ICC color profiles when converting and resizing images
Image.open() reads the source's ICC profile into img.info, but that dict resets to empty after convert() or resize(), and save() was never passing icc_profile anyway. DCI-P3 and Rec.2020 photos were silently falling back to sRGB interpretation once processed.
2026-08-17 15:13:36 +02:00
Djeex a938234495 Merge pull request 'Hotfix - Chrome HDR artefacting fix' (#26) from dev into main
Reviewed-on: #26
2026-08-16 23:06:36 +02:00
Djeex 193b1704f8 Chrome HDR artefacting fix 2026-08-16 22:43:02 +02:00
Djeex 9d56d729c1 v2.1.2 2026-08-16 22:43:02 +02:00
Djeex 40a01c4701 Merge pull request 'Multi-stage smaller alpine docker image and python 3.13' (#24) from alpine into main
Reviewed-on: #24
2025-09-19 22:51:22 +02:00
Djeex 28e3b087b0 Alpine - smaller image 2025-09-19 22:40:30 +02:00
Djeex 116a9574ce Merge branch 'Beta-2.1' 2025-09-04 11:23:59 +00:00
Djeex 6c7216c0e1 Hotfix - default copy 2025-09-04 13:18:42 +02:00
Djeex 7c98a6ceaf Merge pull request 'Beta-2.1 - The clearer, the faster' (#22) from Beta-2.1 into main
Reviewed-on: #22
2025-09-04 12:50:26 +02:00
Djeex 69d303c7a1 Refactored site-info 2025-09-04 12:28:01 +02:00
Djeex 1d1d7d8304 Refactored Galley Editor 2025-09-04 12:22:52 +02:00
Djeex 205dcae2bc Refactored upload.js 2025-09-04 12:16:08 +02:00
Djeex b7fdcacf77 Fixed wrong focus and suggestion drop down delay 2025-09-04 12:11:58 +02:00
Djeex 9cdf6bbd32 Fixed hero images 2025-09-04 11:10:04 +02:00
Djeex f92060603d Fixed custom font issue 2025-09-04 10:41:58 +02:00
Djeex 54e9281793 Fixed mobile theme css issue 2025-09-04 10:10:39 +02:00
Djeex 5fb6ef18d1 Fixed log issues 2025-09-04 10:00:57 +02:00
Djeex 1119647884 Fixed default file copy 2025-09-04 09:56:56 +02:00
Djeex 2cc0a213c9 Fixed laggy menu appearance 2025-09-04 00:24:54 +02:00
Djeex 24113a4aa8 Fixed build section status 2025-09-03 10:43:54 +02:00
Djeex be8ce4f62c Fixed message theme selector 2025-09-03 10:18:05 +02:00
Djeex edca473e29 New suggestion list css 2025-09-03 10:12:18 +02:00
Djeex eac90820d2 Fixed hero img fade in 2025-09-03 09:43:52 +02:00
Djeex 191fa82711 Cleaning 2025-09-02 23:44:18 +02:00
Djeex b17652e471 Fade in img 2025-09-02 23:10:23 +02:00
Djeex 65fd62e342 Untagged filter 2025-09-02 22:53:21 +02:00
Djeex 6b03ee30fa Fixes save menu issues 2025-09-02 21:58:58 +02:00
Djeex fd45ebbd53 Fixed UI and conf template 2025-09-02 20:40:02 +02:00
Djeex 795f5fbd13 Redone form save logic 2025-09-02 14:44:54 +02:00
Djeex f8bebb9c95 Fixed forms and key issues 2025-09-01 22:13:16 +00:00
Djeex 3198755576 Top save button + fixed some CSS 2025-09-01 16:46:43 +00:00
Djeex f98f2d598f Fixed footer icon and git link 2025-09-01 16:16:27 +00:00
Djeex e8718e71ab Fixed port 2025-09-01 14:59:13 +00:00
Djeex 5b65e5efe3 Better logging 2025-09-01 14:57:52 +00:00
Djeex 021e0c7974 Preview link 2025-09-01 14:37:31 +00:00
Djeex debbf07280 New bottom upload btn + images count 2025-08-31 14:34:22 +02:00
Djeex df96782500 Merge pull request 'Fixing footer link and menu appear' (#17) from fixing into main
Reviewed-on: #17
2025-08-28 19:21:59 +02:00
Djeex febf4d2be5 Version 2025-08-28 19:13:01 +02:00
Djeex 617545e1bb Fixed img margin-bottom for mobile 2025-08-28 18:49:46 +02:00
Djeex 922ce99679 Fixed menu appear > 6 2025-08-28 18:46:45 +02:00
Djeex cd0428990a Footer link updated 2025-08-28 18:36:07 +02:00
Djeex d3af86be8c Hotfix - code hanging 2025-08-26 20:47:51 +02:00
29 changed files with 1345 additions and 949 deletions
+1
View File
@@ -1,4 +1,5 @@
.* .*
!.env
!.sh !.sh
!.gitignore !.gitignore
output/ output/
+11 -6
View File
@@ -1,10 +1,18 @@
FROM python:3.11-slim FROM python:3.13-alpine AS builder
WORKDIR /app WORKDIR /app
COPY requirements.txt . RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
RUN pip install --no-cache-dir -r requirements.txt COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
FROM python:3.13-alpine
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --find-links=/wheels /wheels/* && rm -rf /wheels
COPY build.py gallery.py VERSION /app/ COPY build.py gallery.py VERSION /app/
COPY ./src/ ./src/ COPY ./src/ ./src/
@@ -12,7 +20,4 @@ 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
RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
ENTRYPOINT ["/app/entrypoint.sh"] ENTRYPOINT ["/app/entrypoint.sh"]
+2
View File
@@ -12,6 +12,8 @@
Lumeex is a static site generator designed to create minimalist photo galleries that highlight your artworks over the author. It empowers users to organize and explore images using tags, with each page load presenting photos in a random order to encourage discovery of new content. Lumeex is a static site generator designed to create minimalist photo galleries that highlight your artworks over the author. It empowers users to organize and explore images using tags, with each page load presenting photos in a random order to encourage discovery of new content.
👉 __Check the online version : [Lumeex-web](https://lumeex.djeex.fr)__
The project includes two thoughtfully designed themes—one modern, one minimalistic—both crafted to keep the spotlight on your photos: The project includes two thoughtfully designed themes—one modern, one minimalistic—both crafted to keep the spotlight on your photos:
- **Modern** — [View Demo](https://modern.djeex.fr) - **Modern** — [View Demo](https://modern.djeex.fr)
+1 -1
View File
@@ -1 +1 @@
2.0.0 2.1.2
+7 -10
View File
@@ -1,4 +1,3 @@
# Please change this by your settings.
info: info:
title: title:
subtitle: subtitle:
@@ -9,7 +8,7 @@ info:
social: social:
instagram_url: instagram_url:
thumbnail: thumbnail: ''
menu: menu:
items: items:
@@ -18,19 +17,17 @@ menu:
footer: footer:
copyright: Copyright © 2025 copyright: Copyright © 2025
legal_link: '/legals/' legal_link: /legals/
legal_label: Legal notice legal_label: Legal notice
# Build parameters
build: build:
theme: modern # choose a theme in config/theme folder theme: modern
convert_images: true # true to enable image conversion convert_images: true
resize_images: true # true to enable image resizing resize_images: true
# Change this by your legals
legals: legals:
hoster_name: hoster_name:
hoster_adress: hoster_address:
hoster_contact: hoster_contact:
intellectual_property: intellectual_property:
- paragraph: "" - paragraph: ''
+1 -4
View File
@@ -35,16 +35,13 @@ img, tag {
#footer { #footer {
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5); box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
margin: auto;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
#footer { #footer {
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5); box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
border-radius: 15px 15px 0 0;
max-width: 1140px; max-width: 1140px;
margin: 0;
} }
.tag { .tag {
@@ -53,7 +50,7 @@ img, tag {
.hero-background { .hero-background {
max-width: 90%; max-width: 90%;
margin: auto; margin: 0 auto;
} }
.back-button { .back-button {
+16 -17
View File
@@ -1,19 +1,25 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
secondary: '#00b0f0' secondary: '#00B0F0'
accent: '#ffc700' accent: '#FFC700'
text_dark: '#616161' text_dark: '#616161'
background: '#ffffff' background: '#FFFFFF'
browser_color: '#ffffff' browser_color: '#FFFFFF'
favicon: favicon:
path: favicon.png path: favicon.png
fonts:
primary:
fallback: sans-serif
name: Lato
secondary:
fallback: serif
name: Montserrat
google_fonts: google_fonts:
- family: Lato - family: 'Lato'
weights: weights:
- '200' - '200'
- '400' - '400'
@@ -23,10 +29,3 @@ google_fonts:
- '200' - '200'
- '400' - '400'
- '700' - '700'
fonts:
primary:
name: Lato
fallback: sans-serif
secondary:
name: Montserrat
fallback: serif
+7 -11
View File
@@ -1,21 +1,17 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
secondary: '#00b0f0' secondary: '#00B0F0'
accent: '#ffc700'
text_dark: '#616161' text_dark: '#616161'
background: '#ffffff'
browser_color: '#ffffff'
favicon: favicon:
path: favicon.png path: favicon.png
fonts: fonts:
primary: primary:
name: Trixie name: trixie
fallback: sans-serif fallback: sans-serif
secondary: secondary:
name: Trixie name: trixie
fallback: serif fallback: serif
+16 -17
View File
@@ -1,19 +1,25 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
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:
primary:
fallback: sans-serif
name: Lato
secondary:
fallback: serif
name: Montserrat
google_fonts: google_fonts:
- family: Lato - family: ''
weights: weights:
- '200' - '200'
- '400' - '400'
@@ -23,10 +29,3 @@ google_fonts:
- '200' - '200'
- '400' - '400'
- '700' - '700'
fonts:
primary:
name: Lato
fallback: sans-serif
secondary:
name: Montserrat
fallback: serif
+9 -11
View File
@@ -1,21 +1,19 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
secondary: '#00b0f0' secondary: '#00B0F0'
accent: '#ffc700'
text_dark: '#616161' text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
favicon: favicon:
path: favicon.png path: favicon.png
fonts: fonts:
primary: primary:
name: Trixie name: trixie.woff
fallback: sans-serif fallback: sans-serif
secondary: secondary:
name: Trixie name: trixie.woff
fallback: serif fallback: serif
+2
View File
@@ -0,0 +1,2 @@
PREVIEW_PORT=3000
WEBUI_PORT=5000
+23 -18
View File
@@ -1,33 +1,36 @@
#!/bin/bash #!/bin/sh
set -e set -e
CYAN="\033[1;36m" CYAN="\033[1;36m"
NC="\033[0m" NC="\033[0m"
copy_default_config() { copy_default_config() {
echo "Checking configuration directory..." echo "[~] Checking configuration directory..."
if [ ! -d "/app/config" ]; then
mkdir -p /app/config mkdir -p /app/config
fi
echo "Checking if default config files need to be copied..." echo "[~] Checking if default config files/folders need to be copied..."
files_copied=false files_copied=false
for file in /app/default/*; do for src in $(find /app/default -mindepth 1); do
filename=$(basename "$file") relpath="${src#/app/default/}"
target="/app/config/$filename" target="/app/config/$relpath"
if [ ! -e "$target" ]; then if [ ! -e "$target" ]; then
echo "Copying default config file: $filename" echo "[→] Copying: $relpath"
cp -r "$file" "$target" if [ -d "$src" ]; then
mkdir -p "$target"
cp -r "$src/" "$target/"
else
mkdir -p "$(dirname "$target")"
cp "$src" "$target"
fi
files_copied=true files_copied=true
fi fi
done done
if [ "$files_copied" = true ]; then if [ "$files_copied" = true ]; then
echo "Default configuration files copied successfully." echo "[✓] Default configuration files/folders copied successfully."
else else
echo "No default files needed to be copied." echo "[✓] No default files/folders needed to be copied."
fi fi
} }
@@ -43,11 +46,13 @@ 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 preview HTTP server on port 3000..." PREVIEW_PORT="${PREVIEW_PORT:-3000}"
echo "[~]Starting preview HTTP server on port 3000..."
echo "[i] Preview host port is set to: ${PREVIEW_PORT}"
python3 -u -m http.server 3000 -d /app/output & python3 -u -m http.server 3000 -d /app/output &
SERVER_PID=$! SERVER_PID=$!
echo "Starting Lumeex Flask webui..." echo "[~] Starting Lumeex Flask webui..."
python3 -u -m src.py.webui.webui & python3 -u -m src.py.webui.webui &
WEBUI_PID=$! WEBUI_PID=$!
@@ -72,15 +77,15 @@ fi
case "$1" in case "$1" in
build) build)
echo "Running build.py..." echo "[~] Running build.py..."
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
;; ;;
gallery) gallery)
echo "Running gallery.py..." echo "[~] Running gallery.py..."
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2 python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
;; ;;
*) *)
echo "Unknown command: $1" echo "[!] Unknown command: $1"
exec "$@" exec "$@"
;; ;;
esac esac
+7 -2
View File
@@ -2,10 +2,15 @@ services:
lumeex: lumeex:
container_name: lmx container_name: lmx
build: .. build: ..
env_file:
- .env
environment:
- PREVIEW_PORT=${PREVIEW_PORT:-3000} # port for preview server - set it in .env file
- WEBUI_PORT=${WEBUI_PORT:-5000} # port for webui server - set it in .env file
volumes: volumes:
- ../config:/app/config # mount config directory - ../config:/app/config # mount config directory
- ../output:/app/output # mount output directory - ../output:/app/output # mount output directory
ports: ports:
- "3000:3000" - "${PREVIEW_PORT:-3000}:3000"
- "5000:5000" - "${WEBUI_PORT:-5000}:5000"
+8 -1
View File
@@ -9,7 +9,6 @@ window.addEventListener("DOMContentLoaded", () => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
const img = entry.target; const img = entry.target;
console.log("Lazy-loading image:", img.dataset.src);
img.src = img.dataset.src; img.src = img.dataset.src;
img.onload = () => { img.onload = () => {
img.classList.add("loaded"); img.classList.add("loaded");
@@ -33,6 +32,14 @@ window.addEventListener("DOMContentLoaded", () => {
img.classList.add("loaded"); img.classList.add("loaded");
}; };
// Chrome HDR artefacting fix
const clearWillChange = (e) => {
if (e.propertyName !== "transform") return;
img.style.willChange = "auto";
img.removeEventListener("transitionend", clearWillChange);
};
img.addEventListener("transitionend", clearWillChange);
if (img.complete && img.naturalHeight !== 0) { if (img.complete && img.naturalHeight !== 0) {
onLoad(); // already loaded onLoad(); // already loaded
} else { } else {
+8
View File
@@ -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 {
+22 -36
View File
@@ -78,6 +78,8 @@ html,body {
font-weight: 400; font-weight: 400;
line-height:1.5; line-height:1.5;
color:var(--color-primary-dark); color:var(--color-primary-dark);
display: flex;
flex-direction: column;
} }
html { html {
@@ -179,8 +181,8 @@ h2 {
/* animation */ /* animation */
.appear { .appear {
-webkit-transition: all 0.3s; -webkit-transition: all 1s;
transition: all 0.3s; transition: all 1s;
opacity: 0; opacity: 0;
-webkit-transform: translateY(20px); -webkit-transform: translateY(20px);
transform: translateY(20px); transform: translateY(20px);
@@ -192,36 +194,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 {
@@ -267,12 +239,23 @@ h2 {
/* Hero */ /* Hero */
#hero { #hero {
min-height: 100vh;
flex: 1 0 auto;
display: flex;
flex-direction: column;
}
#hero .section {
height: 100%; height: 100%;
display: flex;
flex-direction: column;
width: 100%; width: 100%;
} }
#hero .content-wrapper, #hero .section { #hero .content-wrapper, #hero .section {
height:100%; height:100%;
} }
.hero-background { .hero-background {
height: 66%; height: 66%;
width: 100%; width: 100%;
@@ -333,7 +316,6 @@ h2 {
font-size: 22px; font-size: 22px;
} }
.gallery { .gallery {
padding-top: 15px; padding-top: 15px;
} }
@@ -371,6 +353,11 @@ h2 {
/* Footer */ /* Footer */
#footer {
margin: auto 0 0 0;
width: 100%;
}
.navigation { .navigation {
text-align: center; text-align: center;
padding-top: 40px; padding-top: 40px;
@@ -491,7 +478,7 @@ h2 {
} }
.section img { .section img {
margin: 0px 0 60px 0; margin: 0px 0 40px 0;
} }
.tag { .tag {
@@ -500,8 +487,7 @@ h2 {
#legals.content-wrapper { #legals.content-wrapper {
max-width: 90%; max-width: 90%;
margin: auto; margin: 50px auto;
margin-top: 50px;
} }
.legals-content { .legals-content {
+5 -1
View File
@@ -11,6 +11,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
return return
img = Image.open(input_path) img = Image.open(input_path)
icc_profile = img.info.get("icc_profile")
if img.mode != "RGB": if img.mode != "RGB":
img = img.convert("RGB") img = img.convert("RGB")
@@ -27,7 +28,10 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
if fmt == "JPEG": if fmt == "JPEG":
output_path = output_path.with_suffix(".jpg") output_path = output_path.with_suffix(".jpg")
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100) save_kwargs = {"quality": 90 if fmt == "JPEG" else 100}
if icc_profile:
save_kwargs["icc_profile"] = icc_profile
img.save(output_path, fmt, **save_kwargs)
logging.info(f"[✓] Processed image: {input_path}{output_path}") logging.info(f"[✓] Processed image: {input_path}{output_path}")
except Exception as e: except Exception as e:
+26 -5
View File
@@ -30,6 +30,8 @@ app = Flask(
static_url_path="" static_url_path=""
) )
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
# --- Config paths --- # --- Config paths ---
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml" SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos" PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
@@ -72,9 +74,14 @@ def get_local_fonts(theme_name):
def index(): def index():
return render_template("index.html") return render_template("index.html")
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
@app.context_processor @app.context_processor
def inject_version(): def inject_version():
return dict(lumeex_version=lumeex_version) return dict(
lumeex_version=lumeex_version,
preview_port=PREVIEW_PORT
)
# --- Gallery & Hero API --- # --- Gallery & Hero API ---
@app.route("/gallery-editor") @app.route("/gallery-editor")
@@ -203,9 +210,21 @@ def get_site_info():
@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.""" """Update site info YAML."""
data = request.json new_data = request.json
with open(SITE_YAML, "r") as f:
old_data = yaml.safe_load(f) or {}
def deep_merge(old, new):
for k, v in new.items():
if isinstance(v, dict) and isinstance(old.get(k), dict):
old[k] = deep_merge(old[k], v)
else:
old[k] = v
return old
merged = deep_merge(old_data, new_data)
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(merged, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Theme management --- # --- Theme management ---
@@ -396,7 +415,8 @@ def upload_font():
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True) fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename) file.save(fonts_dir / file.filename)
return jsonify({"status": "ok", "filename": file.filename}) font_basename = Path(file.filename).stem
return jsonify({"status": "ok", "filename": font_basename})
@app.route("/api/font/remove", methods=["POST"]) @app.route("/api/font/remove", methods=["POST"])
def remove_font(): def remove_font():
@@ -487,5 +507,6 @@ def download_output_zip():
# --- Run server --- # --- Run server ---
if __name__ == "__main__": if __name__ == "__main__":
logging.info("Starting WebUI at http://0.0.0.0:5000") logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}")
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)
+1 -1
View File
@@ -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>
+28
View File
@@ -18,7 +18,16 @@
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button> <button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
</div> </div>
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden> <input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<span id="hero-count" class="photo-count"></span>
<div id="hero"></div> <div id="hero"></div>
<input type="file" id="upload-hero-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-hero-upload" class="upload-actions-row bottom-action-row">
<span id="hero-count-bottom" class="photo-count"></span>
<label for="upload-hero-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-hero-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div> </div>
<!-- Gallery Upload Section --> <!-- Gallery Upload Section -->
@@ -31,8 +40,27 @@
</label> </label>
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button> <button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
</div> </div>
<div class="filter-row">
<label>
<input type="radio" name="gallery-filter" id="show-all-radio" checked>
All
</label>
<label>
<input type="radio" name="gallery-filter" id="show-untagged-radio">
Untagged
</label>
</div>
<span id="gallery-count" class="photo-count"></span>
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden> <input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="gallery"></div> <div id="gallery"></div>
<input type="file" id="upload-gallery-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-gallery-upload" class="upload-actions-row bottom-action-row">
<span id="gallery-count-bottom" class="photo-count"></span>
<label for="upload-gallery-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-gallery-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div> </div>
<div class="section"> <div class="section">
+12
View File
@@ -92,6 +92,18 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
} }
// Preview Site button
const previewBtn = document.getElementById("preview-site-btn");
if (previewBtn) {
const previewPort = previewBtn.getAttribute("data-preview-port") || "3000";
previewBtn.onclick = () => {
const host = window.location.hostname;
const protocol = window.location.protocol;
const url = `${protocol}//${host}:${previewPort}/`;
window.open(url, "_blank");
};
}
// Modal close logic // Modal close logic
if (buildModal && buildModalClose) { if (buildModal && buildModalClose) {
buildModalClose.onclick = () => { buildModalClose.onclick = () => {
+90 -78
View File
@@ -1,18 +1,29 @@
// --- Arrays to store gallery and hero images ---
let galleryImages = []; let galleryImages = [];
let heroImages = []; let heroImages = [];
let allTags = []; // global tag list let allTags = [];
let showOnlyUntagged = false;
// --- Fade-in helper ---
function applyFadeInImages(container) {
container.querySelectorAll("img.fade-in-img").forEach(img => {
const onLoad = () => img.classList.add("loaded");
if (img.complete && img.naturalHeight !== 0) onLoad();
else {
img.addEventListener("load", onLoad, { once: true });
img.addEventListener("error", () => {
console.warn("Image failed to load:", img.dataset?.src || img.src);
});
}
});
}
// --- Load images from server on page load --- // --- Load images from server on page load ---
async function loadData() { async function loadData() {
try { try {
const galleryRes = await fetch('/api/gallery'); galleryImages = await (await fetch('/api/gallery')).json();
galleryImages = await galleryRes.json();
updateAllTags(); updateAllTags();
renderGallery(); renderGallery();
heroImages = await (await fetch('/api/hero')).json();
const heroRes = await fetch('/api/hero');
heroImages = await heroRes.json();
renderHero(); renderHero();
} catch(err) { } catch(err) {
console.error(err); console.error(err);
@@ -24,22 +35,43 @@ async function loadData() {
function updateAllTags() { function updateAllTags() {
allTags = []; allTags = [];
galleryImages.forEach(img => { galleryImages.forEach(img => {
if (img.tags) img.tags.forEach(t => { (img.tags || []).forEach(t => {
if (!allTags.includes(t)) allTags.push(t); if (!allTags.includes(t)) allTags.push(t);
}); });
}); });
} }
// --- Helper: update count and button visibility ---
function updateCountAndButtons(prefix, count) {
const countTop = document.getElementById(`${prefix}-count`);
const countBottom = document.getElementById(`${prefix}-count-bottom`);
if (countTop) countTop.innerHTML = `<p>${count} photos</p>`;
if (countBottom) countBottom.innerHTML = `<p>${count} photos</p>`;
const removeAllBtn = document.getElementById(`remove-all-${prefix}`);
const removeAllBtnBottom = document.getElementById(`remove-all-${prefix}-bottom`);
if (removeAllBtn) removeAllBtn.style.display = count > 0 ? 'inline-block' : 'none';
if (removeAllBtnBottom) removeAllBtnBottom.style.display = count > 0 ? 'inline-block' : 'none';
const bottomUpload = document.getElementById(`bottom-${prefix}-upload`);
if (bottomUpload) bottomUpload.style.display = count > 0 ? 'flex' : 'none';
}
// --- Render gallery images with tags and delete buttons --- // --- Render gallery images with tags and delete buttons ---
function renderGallery() { function renderGallery() {
const container = document.getElementById('gallery'); const container = document.getElementById('gallery');
container.innerHTML = ''; container.innerHTML = '';
galleryImages.forEach((img, i) => { let imagesToShow = showOnlyUntagged
? galleryImages.filter(img => !img.tags || img.tags.length === 0)
: galleryImages;
imagesToShow.forEach((img) => {
const i = galleryImages.indexOf(img);
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'photo flex-item flex-column'; div.className = 'photo flex-item flex-column';
div.innerHTML = ` div.innerHTML = `
<div class="flex-item"> <div class="flex-item">
<img src="/photos/${img.src}"> <img class="fade-in-img" src="/photos/${img.src}">
</div> </div>
<div class="tags-display" data-index="${i}"></div> <div class="tags-display" data-index="${i}"></div>
<div class="flex-item flex-full"> <div class="flex-item flex-full">
@@ -50,22 +82,17 @@ function renderGallery() {
</div> </div>
`; `;
container.appendChild(div); container.appendChild(div);
renderTags(i, img.tags || []); renderTags(i, img.tags || []);
}); });
// Show/hide Remove All button updateCountAndButtons('gallery', imagesToShow.length);
const removeAllBtn = document.getElementById('remove-all-gallery'); applyFadeInImages(container);
if (removeAllBtn) {
removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
}
} }
// --- Render tags for a single image --- // --- Render tags for a single image ---
function renderTags(imgIndex, tags) { function renderTags(imgIndex, tags) {
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`); const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`); const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
tagsDisplay.innerHTML = ''; tagsDisplay.innerHTML = '';
inputContainer.innerHTML = ''; inputContainer.innerHTML = '';
@@ -73,7 +100,6 @@ function renderTags(imgIndex, tags) {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'tag'; span.className = 'tag';
span.textContent = tag; span.textContent = tag;
const remove = document.createElement('span'); const remove = document.createElement('span');
remove.className = 'remove-tag'; remove.className = 'remove-tag';
remove.textContent = '×'; remove.textContent = '×';
@@ -82,7 +108,6 @@ function renderTags(imgIndex, tags) {
updateTags(imgIndex, tags); updateTags(imgIndex, tags);
renderTags(imgIndex, tags); renderTags(imgIndex, tags);
}; };
span.appendChild(remove); span.appendChild(remove);
tagsDisplay.appendChild(span); tagsDisplay.appendChild(span);
}); });
@@ -92,11 +117,10 @@ 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'); const validateBtn = document.createElement('button');
validateBtn.textContent = '✔️'; validateBtn.textContent = '✔️';
validateBtn.className = 'validate-tag-btn'; validateBtn.className = 'validate-tag-btn';
validateBtn.style.display = 'none'; // hidden by default validateBtn.style.display = 'none';
validateBtn.style.marginLeft = '4px'; validateBtn.style.marginLeft = '4px';
inputContainer.appendChild(validateBtn); inputContainer.appendChild(validateBtn);
@@ -116,30 +140,20 @@ function renderTags(imgIndex, tags) {
const updateSuggestions = () => { const updateSuggestions = () => {
const value = input.value.toLowerCase(); const value = input.value.toLowerCase();
const allTagsFlat = galleryImages.flatMap(img => img.tags || []); const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
const tagCount = {}; const tagCount = {};
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1); allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
const allTagsSorted = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
const allTagsSorted = Object.keys(tagCount)
.sort((a, b) => tagCount[b] - tagCount[a]);
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t)); const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
suggestionBox.innerHTML = ''; suggestionBox.innerHTML = '';
selectedIndex = -1; selectedIndex = -1;
if (suggestions.length) { if (suggestions.length) {
suggestionBox.style.display = 'block'; suggestionBox.style.display = 'block';
suggestions.forEach((s, idx) => { suggestions.forEach((s, idx) => {
const li = document.createElement('li'); const li = document.createElement('li');
li.style.fontStyle = 'italic'; li.style.fontStyle = 'italic';
li.style.textAlign = 'left'; li.style.textAlign = 'left';
li.innerHTML = `<b>${s.substring(0, input.value.length)}</b>${s.substring(input.value.length)}`;
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
const rest = s.substring(input.value.length);
li.innerHTML = boldPart + rest;
li.addEventListener('mousedown', (e) => { li.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
addTag(s); addTag(s);
@@ -147,7 +161,6 @@ function renderTags(imgIndex, tags) {
input.focus(); input.focus();
updateSuggestions(); updateSuggestions();
}); });
li.onmouseover = () => selectedIndex = idx; li.onmouseover = () => selectedIndex = idx;
suggestionBox.appendChild(li); suggestionBox.appendChild(li);
}); });
@@ -164,7 +177,6 @@ function renderTags(imgIndex, tags) {
updateSuggestions(); updateSuggestions();
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none'; 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');
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
@@ -195,27 +207,22 @@ function renderTags(imgIndex, tags) {
validateBtn.style.display = 'none'; validateBtn.style.display = 'none';
} }
}); });
input.addEventListener('blur', () => { input.addEventListener('blur', () => {
setTimeout(() => {
suggestionBox.style.display = 'none'; suggestionBox.style.display = 'none';
input.value = ''; input.value = '';
validateBtn.style.display = 'none'; validateBtn.style.display = 'none';
}, 150);
}); });
validateBtn.addEventListener('mousedown', (e) => {
// --- Validate button action --- e.preventDefault();
validateBtn.onclick = () => {
if (input.value.trim()) { if (input.value.trim()) {
addTag(input.value.trim()); addTag(input.value.trim());
input.value = ''; input.value = '';
updateSuggestions(); updateSuggestions();
validateBtn.style.display = 'none'; validateBtn.style.display = 'none';
} }
}; });
input.focus();
updateSuggestions(); updateSuggestions();
if (!input.value.trim()) suggestionBox.style.display = 'none';
} }
// --- Update tags in galleryImages array --- // --- Update tags in galleryImages array ---
@@ -233,7 +240,7 @@ function renderHero() {
div.className = 'photo flex-item flex-column'; div.className = 'photo flex-item flex-column';
div.innerHTML = ` div.innerHTML = `
<div class="flex-item"> <div class="flex-item">
<img src="/photos/${img.src}"> <img class="fade-in-img" src="/photos/${img.src}">
</div> </div>
<div class="flex-item flex-full"> <div class="flex-item flex-full">
<div class="flex-item flex-end"> <div class="flex-item flex-end">
@@ -243,12 +250,8 @@ function renderHero() {
`; `;
container.appendChild(div); container.appendChild(div);
}); });
updateCountAndButtons('hero', heroImages.length);
// Show/hide Remove All button applyFadeInImages(container);
const removeAllBtn = document.getElementById('remove-all-hero');
if (removeAllBtn) {
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
}
} }
// --- Save gallery to server --- // --- Save gallery to server ---
@@ -294,33 +297,27 @@ async function refreshHero() {
function showToast(message, type = "success", duration = 3000) { function showToast(message, type = "success", duration = 3000) {
const container = document.getElementById("toast-container"); const container = document.getElementById("toast-container");
if (!container) return; if (!container) return;
const toast = document.createElement("div"); const toast = document.createElement("div");
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show")); requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => { setTimeout(() => {
toast.classList.remove("show"); toast.classList.remove("show");
toast.addEventListener("transitionend", () => toast.remove()); toast.addEventListener("transitionend", () => toast.remove());
}, duration); }, duration);
} }
let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null } let pendingDelete = null;
// --- Show delete confirmation modal --- // --- Show delete confirmation modal ---
function showDeleteModal(type, index = null) { function showDeleteModal(type, index = null) {
pendingDelete = { type, index }; pendingDelete = { type, index };
const modalText = document.getElementById('delete-modal-text'); const modalText = document.getElementById('delete-modal-text');
if (type === 'gallery-all') { modalText.textContent =
modalText.textContent = "Are you sure you want to delete ALL gallery images?"; type === 'gallery-all' ? "Are you sure you want to delete ALL gallery images?"
} else if (type === 'hero-all') { : type === 'hero-all' ? "Are you sure you want to delete ALL hero images?"
modalText.textContent = "Are you sure you want to delete ALL hero images?"; : "Are you sure you want to delete this image?";
} else {
modalText.textContent = "Are you sure you want to delete this image?";
}
document.getElementById('delete-modal').style.display = 'flex'; document.getElementById('delete-modal').style.display = 'flex';
} }
@@ -333,15 +330,10 @@ function hideDeleteModal() {
// --- Confirm deletion --- // --- Confirm deletion ---
async function confirmDelete() { async function confirmDelete() {
if (!pendingDelete) return; if (!pendingDelete) return;
if (pendingDelete.type === 'gallery') { if (pendingDelete.type === 'gallery') await actuallyDeleteGalleryImage(pendingDelete.index);
await actuallyDeleteGalleryImage(pendingDelete.index); else if (pendingDelete.type === 'hero') await actuallyDeleteHeroImage(pendingDelete.index);
} else if (pendingDelete.type === 'hero') { else if (pendingDelete.type === 'gallery-all') await actuallyDeleteAllGalleryImages();
await actuallyDeleteHeroImage(pendingDelete.index); else if (pendingDelete.type === 'hero-all') await actuallyDeleteAllHeroImages();
} else if (pendingDelete.type === 'gallery-all') {
await actuallyDeleteAllGalleryImages();
} else if (pendingDelete.type === 'hero-all') {
await actuallyDeleteAllHeroImages();
}
hideDeleteModal(); hideDeleteModal();
} }
@@ -423,15 +415,35 @@ async function actuallyDeleteAllHeroImages() {
// --- Modal event listeners and bulk delete buttons --- // --- Modal event listeners and bulk delete buttons ---
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('delete-modal-close').onclick = hideDeleteModal; ['delete-modal-close', 'delete-modal-cancel'].forEach(id => {
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal; const el = document.getElementById(id);
document.getElementById('delete-modal-confirm').onclick = confirmDelete; if (el) el.onclick = hideDeleteModal;
});
const confirmBtn = document.getElementById('delete-modal-confirm');
if (confirmBtn) confirmBtn.onclick = confirmDelete;
// Gallery filter radios
const showAllRadio = document.getElementById('show-all-radio');
const showUntaggedRadio = document.getElementById('show-untagged-radio');
if (showAllRadio) showAllRadio.addEventListener('change', () => {
showOnlyUntagged = false;
renderGallery();
});
if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => {
showOnlyUntagged = true;
renderGallery();
});
// Bulk delete buttons // Bulk delete buttons
const removeAllGalleryBtn = document.getElementById('remove-all-gallery'); [
const removeAllHeroBtn = document.getElementById('remove-all-hero'); ['remove-all-gallery', 'gallery-all'],
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all'); ['remove-all-gallery-bottom', 'gallery-all'],
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all'); ['remove-all-hero', 'hero-all'],
['remove-all-hero-bottom', 'hero-all']
].forEach(([btnId, type]) => {
const btn = document.getElementById(btnId);
if (btn) btn.onclick = () => showDeleteModal(type);
});
}); });
// --- Initialize --- // --- Initialize ---
+301 -223
View File
@@ -12,7 +12,6 @@ function showToast(message, type = "success", duration = 3000) {
}, duration); }, duration);
} }
// --- Loader helpers ---
function showLoader(text = "Uploading...") { function showLoader(text = "Uploading...") {
const loader = document.getElementById("global-loader"); const loader = document.getElementById("global-loader");
if (loader) { if (loader) {
@@ -26,119 +25,99 @@ function hideLoader() {
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// Form and menu logic // --- Section Forms ---
const form = document.getElementById("site-info-form"); const forms = {
info: document.getElementById("info-form"),
social: document.getElementById("social-form"),
menu: document.getElementById("menu-form"),
footer: document.getElementById("footer-form"),
legals: document.getElementById("legals-form"),
build: document.getElementById("build-form")
};
// --- Menu logic ---
const menuList = document.getElementById("menu-items-list"); const menuList = document.getElementById("menu-items-list");
const addMenuBtn = document.getElementById("add-menu-item"); const addMenuBtn = document.getElementById("add-menu-item");
let menuItems = []; let menuItems = [];
// Render menu items
function renderMenuItems() { function renderMenuItems() {
menuList.innerHTML = ""; menuList.innerHTML = "";
menuItems.forEach((item, idx) => { menuItems.forEach((item, idx) => {
const div = document.createElement("div"); menuList.innerHTML += `
div.style.display = "flex"; <div style="display:flex;gap:8px;margin-bottom:6px;">
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required> <input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required> <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>
</div>
`; `;
menuList.appendChild(div);
}); });
} }
// Update menu items from inputs
function updateMenuItemsFromInputs() { function updateMenuItemsFromInputs() {
const inputs = menuList.querySelectorAll("input"); const inputs = menuList.querySelectorAll("input");
const items = []; menuItems = [];
for (let i = 0; i < inputs.length; i += 2) { for (let i = 0; i < inputs.length; i += 2) {
const label = inputs[i].value.trim(); const label = inputs[i].value.trim();
const href = inputs[i + 1].value.trim(); const href = inputs[i + 1].value.trim();
if (label || href) items.push({ label, href }); if (label || href) menuItems.push({ label, href });
} }
menuItems = items;
} }
// Intellectual property paragraphs logic // --- Intellectual property paragraphs logic ---
const ipList = document.getElementById("ip-list"); const ipList = document.getElementById("ip-list");
const addIpBtn = document.getElementById("add-ip-paragraph"); const addIpBtn = document.getElementById("add-ip-paragraph");
let ipParagraphs = []; let ipParagraphs = [];
// Render IP paragraphs
function renderIpParagraphs() { function renderIpParagraphs() {
ipList.innerHTML = ""; ipList.innerHTML = "";
ipParagraphs.forEach((item, idx) => { ipParagraphs.forEach((item, idx) => {
const div = document.createElement("div"); ipList.innerHTML += `
div.style.display = "flex"; <div style="display:flex;gap:8px;margin-bottom:6px;">
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
<textarea placeholder="Paragraph" required 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>
</div>
`; `;
ipList.appendChild(div);
}); });
} }
// Update IP paragraphs from textareas
function updateIpParagraphsFromInputs() { function updateIpParagraphsFromInputs() {
const textareas = ipList.querySelectorAll("textarea"); ipParagraphs = Array.from(ipList.querySelectorAll("textarea"))
ipParagraphs = Array.from(textareas).map(textarea => ({ .map(textarea => ({ paragraph: textarea.value.trim() }))
paragraph: textarea.value.trim() .filter(item => item.paragraph !== "");
})).filter(item => item.paragraph !== "");
} }
// Build options // --- Build options & Theme select ---
const convertImagesCheckbox = document.getElementById("convert-images-checkbox"); const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox"); const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
// Theme select
const themeSelect = document.getElementById("theme-select"); const themeSelect = document.getElementById("theme-select");
// Thumbnail upload and modal logic // --- Thumbnail upload and modal logic ---
const thumbnailInput = form?.elements["social.thumbnail"]; const thumbnailInput = document.getElementById("social-thumbnail");
const thumbnailUpload = document.getElementById("thumbnail-upload"); const thumbnailUpload = document.getElementById("thumbnail-upload");
const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn"); const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
const thumbnailPreview = document.getElementById("thumbnail-preview"); const thumbnailPreview = document.getElementById("thumbnail-preview");
const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn"); const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
// Modal elements for delete confirmation // --- Modal helpers ---
const deleteModal = document.getElementById("delete-modal"); function setupModal(modal, closeBtn, confirmBtn, cancelBtn, onConfirm) {
const deleteModalClose = document.getElementById("delete-modal-close"); if (!modal) return;
const deleteModalConfirm = document.getElementById("delete-modal-confirm"); if (closeBtn) closeBtn.onclick = () => modal.style.display = "none";
const deleteModalCancel = document.getElementById("delete-modal-cancel"); if (cancelBtn) cancelBtn.onclick = () => modal.style.display = "none";
window.addEventListener("click", (e) => {
if (e.target === modal) modal.style.display = "none";
});
if (confirmBtn && onConfirm) confirmBtn.onclick = onConfirm;
}
// Modal elements for theme deletion // --- Thumbnail preview logic ---
const deleteThemeModal = document.getElementById("delete-theme-modal");
const deleteThemeModalClose = document.getElementById("delete-theme-modal-close");
const deleteThemeModalConfirm = document.getElementById("delete-theme-modal-confirm");
const deleteThemeModalCancel = document.getElementById("delete-theme-modal-cancel");
const deleteThemeModalText = document.getElementById("delete-theme-modal-text");
let themeToDelete = null;
// Show/hide thumbnail preview, remove button, and choose button
function updateThumbnailPreview(src) { function updateThumbnailPreview(src) {
if (thumbnailPreview) { if (thumbnailPreview) {
thumbnailPreview.src = src || ""; thumbnailPreview.src = src || "";
thumbnailPreview.style.display = src ? "block" : "none"; thumbnailPreview.style.display = src ? "block" : "none";
} }
if (removeThumbnailBtn) { if (removeThumbnailBtn) removeThumbnailBtn.style.display = src ? "inline-block" : "none";
removeThumbnailBtn.style.display = src ? "inline-block" : "none"; if (chooseThumbnailBtn) chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
}
if (chooseThumbnailBtn) {
chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
}
} }
// Choose thumbnail button triggers file input
if (chooseThumbnailBtn && thumbnailUpload) { if (chooseThumbnailBtn && thumbnailUpload) {
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click()); chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
} }
// Handle thumbnail upload and refresh preview (with cache busting)
if (thumbnailUpload) { if (thumbnailUpload) {
thumbnailUpload.addEventListener("change", async (e) => { thumbnailUpload.addEventListener("change", async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
@@ -156,27 +135,20 @@ document.addEventListener("DOMContentLoaded", () => {
} else { } else {
showToast("❌ Error uploading thumbnail", "error"); showToast("❌ Error uploading thumbnail", "error");
} }
updateSectionStatus("social");
}); });
} }
// Remove thumbnail button triggers modal
if (removeThumbnailBtn) { if (removeThumbnailBtn) {
removeThumbnailBtn.addEventListener("click", () => { removeThumbnailBtn.addEventListener("click", () => {
deleteModal.style.display = "flex"; document.getElementById("delete-modal").style.display = "flex";
}); });
} }
setupModal(
// Modal logic for thumbnail deletion document.getElementById("delete-modal"),
if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) { document.getElementById("delete-modal-close"),
deleteModalClose.onclick = deleteModalCancel.onclick = () => { document.getElementById("delete-modal-confirm"),
deleteModal.style.display = "none"; document.getElementById("delete-modal-cancel"),
}; async () => {
window.onclick = function(event) {
if (event.target === deleteModal) {
deleteModal.style.display = "none";
}
};
deleteModalConfirm.onclick = async () => {
const res = await fetch("/api/thumbnail/remove", { method: "POST" }); const res = await fetch("/api/thumbnail/remove", { method: "POST" });
const result = await res.json(); const result = await res.json();
if (result.status === "ok") { if (result.status === "ok") {
@@ -186,48 +158,38 @@ document.addEventListener("DOMContentLoaded", () => {
} else { } else {
showToast("❌ Error removing thumbnail", "error"); showToast("❌ Error removing thumbnail", "error");
} }
deleteModal.style.display = "none"; document.getElementById("delete-modal").style.display = "none";
}; updateSectionStatus("social");
} }
);
// Theme upload logic (custom theme folder) // --- Theme upload logic ---
const themeUpload = document.getElementById("theme-upload"); const themeUpload = document.getElementById("theme-upload");
const chooseThemeBtn = document.getElementById("choose-theme-btn"); const chooseThemeBtn = document.getElementById("choose-theme-btn");
if (chooseThemeBtn && themeUpload) { if (chooseThemeBtn && themeUpload) {
chooseThemeBtn.addEventListener("click", () => themeUpload.click()); chooseThemeBtn.addEventListener("click", () => themeUpload.click());
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) return;
showLoader("Uploading theme..."); 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(); hideLoader();
if (result.status === "ok") { if (result.status === "ok") {
showToast("✅ Theme uploaded!", "success"); showToast("✅ Theme uploaded!", "success");
// Refresh theme select after upload refreshThemes();
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 { } else {
showToast("❌ Error uploading theme", "error"); showToast("❌ Error uploading theme", "error");
} }
updateSectionStatus("build");
}); });
} }
// Remove theme button triggers modal // --- Remove theme logic ---
const removeThemeBtn = document.getElementById("remove-theme-btn"); const removeThemeBtn = document.getElementById("remove-theme-btn");
let themeToDelete = null;
if (removeThemeBtn && themeSelect) { if (removeThemeBtn && themeSelect) {
removeThemeBtn.addEventListener("click", () => { removeThemeBtn.addEventListener("click", () => {
const theme = themeSelect.value; const theme = themeSelect.value;
@@ -237,24 +199,16 @@ document.addEventListener("DOMContentLoaded", () => {
return; return;
} }
themeToDelete = theme; themeToDelete = theme;
deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`; document.getElementById("delete-theme-modal-text").textContent = `Are you sure you want to remove theme "${theme}"?`;
deleteThemeModal.style.display = "flex"; document.getElementById("delete-theme-modal").style.display = "flex";
}); });
} }
setupModal(
// Modal logic for theme deletion document.getElementById("delete-theme-modal"),
if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) { document.getElementById("delete-theme-modal-close"),
deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => { document.getElementById("delete-theme-modal-confirm"),
deleteThemeModal.style.display = "none"; document.getElementById("delete-theme-modal-cancel"),
themeToDelete = null; async () => {
};
window.onclick = function(event) {
if (event.target === deleteThemeModal) {
deleteThemeModal.style.display = "none";
themeToDelete = null;
}
};
deleteThemeModalConfirm.onclick = async () => {
if (!themeToDelete) return; if (!themeToDelete) return;
showLoader("Removing theme..."); showLoader("Removing theme...");
const res = await fetch("/api/theme/remove", { const res = await fetch("/api/theme/remove", {
@@ -266,28 +220,18 @@ document.addEventListener("DOMContentLoaded", () => {
hideLoader(); hideLoader();
if (result.status === "ok") { if (result.status === "ok") {
showToast("✅ Theme removed!", "success"); showToast("✅ Theme removed!", "success");
// Refresh theme select refreshThemes();
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 { } else {
showToast(result.error || "❌ Error removing theme", "error"); showToast(result.error || "❌ Error removing theme", "error");
} }
deleteThemeModal.style.display = "none"; document.getElementById("delete-theme-modal").style.display = "none";
themeToDelete = null; themeToDelete = null;
}; updateSectionStatus("build");
} }
);
// Fetch theme list and populate select // --- Theme select refresh ---
if (themeSelect) { function refreshThemes() {
fetch("/api/themes") fetch("/api/themes")
.then(res => res.json()) .then(res => res.json())
.then(themes => { .then(themes => {
@@ -298,148 +242,276 @@ document.addEventListener("DOMContentLoaded", () => {
option.textContent = theme; option.textContent = theme;
themeSelect.appendChild(option); themeSelect.appendChild(option);
}); });
// Set selected value after loading config loadConfigAndUpdateBuildStatus();
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
themeSelect.value = data.build?.theme || "";
});
}); });
} }
// Load config from server and populate form // --- Config loading ---
if (form) { let loadedConfig = {};
function loadConfigAndUpdateBuildStatus() {
fetch("/api/site-info") fetch("/api/site-info")
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
loadedConfig = data;
// Info
if (forms.info) {
forms.info.elements["info.title"].value = data.info?.title || "";
forms.info.elements["info.subtitle"].value = data.info?.subtitle || "";
forms.info.elements["info.description"].value = data.info?.description || "";
forms.info.elements["info.canonical"].value = data.info?.canonical || "";
forms.info.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
forms.info.elements["info.author"].value = data.info?.author || "";
}
// Social
if (forms.social) {
forms.social.elements["social.instagram_url"].value = data.social?.instagram_url || "";
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
}
// Menu
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
renderMenuItems();
// Footer
if (forms.footer) {
forms.footer.elements["footer.copyright"].value = data.footer?.copyright || "";
forms.footer.elements["footer.legal_label"].value = data.footer?.legal_label || "";
}
// Legals
ipParagraphs = Array.isArray(data.legals?.intellectual_property) ipParagraphs = Array.isArray(data.legals?.intellectual_property)
? data.legals.intellectual_property ? data.legals.intellectual_property
: []; : [];
renderIpParagraphs(); renderIpParagraphs();
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : []; if (forms.legals) {
renderMenuItems(); forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
form.elements["info.title"].value = data.info?.title || ""; forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
form.elements["info.subtitle"].value = data.info?.subtitle || ""; forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
form.elements["info.description"].value = data.info?.description || "";
form.elements["info.canonical"].value = data.info?.canonical || "";
form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
form.elements["info.author"].value = data.info?.author || "";
form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
form.elements["footer.copyright"].value = data.footer?.copyright || "";
form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
if (themeSelect) {
themeSelect.value = data.build?.theme || "";
}
form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
// Build checkboxes
if (convertImagesCheckbox) {
convertImagesCheckbox.checked = !!data.build?.convert_images;
}
if (resizeImagesCheckbox) {
resizeImagesCheckbox.checked = !!data.build?.resize_images;
} }
// Build
if (themeSelect) themeSelect.value = data.build?.theme || "";
if (convertImagesCheckbox) convertImagesCheckbox.checked = !!data.build?.convert_images;
if (resizeImagesCheckbox) resizeImagesCheckbox.checked = !!data.build?.resize_images;
["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus);
updateSectionStatus("build");
}); });
} }
if (themeSelect) refreshThemes();
else loadConfigAndUpdateBuildStatus();
// Add menu item // --- Add/remove menu items ---
if (addMenuBtn) { if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
addMenuBtn.addEventListener("click", () => {
menuItems.push({ label: "", href: "" }); menuItems.push({ label: "", href: "" });
renderMenuItems(); renderMenuItems();
updateSectionStatus("menu");
}); });
}
// Remove menu item
menuList.addEventListener("click", (e) => { menuList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-menu-item")) { if (e.target.classList.contains("remove-menu-item")) {
const idx = parseInt(e.target.getAttribute("data-idx")); const idx = parseInt(e.target.getAttribute("data-idx"));
menuItems.splice(idx, 1); menuItems.splice(idx, 1);
renderMenuItems(); renderMenuItems();
updateSectionStatus("menu");
} }
}); });
// Update menuItems on input change
menuList.addEventListener("input", () => { menuList.addEventListener("input", () => {
updateMenuItemsFromInputs(); updateMenuItemsFromInputs();
updateSectionStatus("menu");
}); });
// Add paragraph // --- Add/remove IP paragraphs ---
if (addIpBtn) { if (addIpBtn) addIpBtn.addEventListener("click", () => {
addIpBtn.addEventListener("click", () => {
ipParagraphs.push({ paragraph: "" }); ipParagraphs.push({ paragraph: "" });
renderIpParagraphs(); renderIpParagraphs();
updateSectionStatus("legals");
}); });
}
// Remove paragraph
ipList.addEventListener("click", (e) => { ipList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-ip-paragraph")) { if (e.target.classList.contains("remove-ip-paragraph")) {
const idx = parseInt(e.target.getAttribute("data-idx")); const idx = parseInt(e.target.getAttribute("data-idx"));
ipParagraphs.splice(idx, 1); ipParagraphs.splice(idx, 1);
renderIpParagraphs(); renderIpParagraphs();
updateSectionStatus("legals");
} }
}); });
// Update ipParagraphs on input change
ipList.addEventListener("input", () => { ipList.addEventListener("input", () => {
updateIpParagraphsFromInputs(); updateIpParagraphsFromInputs();
updateSectionStatus("legals");
}); });
// Save config to server // --- Section value helpers ---
if (form) { function getSectionValues(section) {
form.addEventListener("submit", async (e) => { switch (section) {
e.preventDefault(); case "info":
return {
title: forms.info.elements["info.title"].value,
subtitle: forms.info.elements["info.subtitle"].value,
description: forms.info.elements["info.description"].value,
canonical: forms.info.elements["info.canonical"].value,
keywords: forms.info.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: forms.info.elements["info.author"].value
};
case "social":
return {
instagram_url: forms.social.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
};
case "menu":
updateMenuItemsFromInputs(); updateMenuItemsFromInputs();
return { items: menuItems };
case "footer":
return {
copyright: forms.footer.elements["footer.copyright"].value,
legal_label: forms.footer.elements["footer.legal_label"].value
};
case "legals":
updateIpParagraphsFromInputs(); updateIpParagraphsFromInputs();
return {
// Check if thumbnail is set before saving (uploaded or present in input) hoster_name: forms.legals.elements["legals.hoster_name"].value,
if (!thumbnailInput || !thumbnailInput.value) { hoster_address: forms.legals.elements["legals.hoster_address"].value,
showLoader("Saving..."); hoster_contact: forms.legals.elements["legals.hoster_contact"].value,
showToast("❌ Thumbnail is required.", "error"); intellectual_property: ipParagraphs
hideLoader(); };
return; case "build":
} return {
const build = {
theme: themeSelect ? themeSelect.value : "", theme: themeSelect ? themeSelect.value : "",
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked), convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked) resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
}; };
default:
const payload = { return {};
info: {
title: form.elements["info.title"].value,
subtitle: form.elements["info.subtitle"].value,
description: form.elements["info.description"].value,
canonical: form.elements["info.canonical"].value,
keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: form.elements["info.author"].value
},
social: {
instagram_url: form.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
},
menu: {
items: menuItems
},
footer: {
copyright: form.elements["footer.copyright"].value,
legal_label: form.elements["footer.legal_label"].value
},
build,
legals: {
hoster_name: form.elements["legals.hoster_name"].value,
hoster_address: form.elements["legals.hoster_address"].value,
hoster_contact: form.elements["legals.hoster_contact"].value,
intellectual_property: ipParagraphs
} }
}; }
// --- REMOVE loader for save ---
// showLoader("Saving..."); function isSectionSaved(section) {
const values = getSectionValues(section);
const config = loadedConfig[section] || {};
function normalizeMenuItems(items) {
return (items || []).map(item => ({
label: item.label || "",
href: item.href || ""
}));
}
switch (section) {
case "info":
return Object.keys(values).every(
key => values[key] && (
key === "keywords"
? Array.isArray(config.keywords) && values.keywords.join(",") === config.keywords.join(",")
: values[key] === (config[key] || "")
)
);
case "social":
return values.instagram_url && values.thumbnail &&
values.instagram_url === (config.instagram_url || "") &&
values.thumbnail === (config.thumbnail || "");
case "menu":
return JSON.stringify(normalizeMenuItems(values.items)) === JSON.stringify(normalizeMenuItems(config.items));
case "footer":
return values.copyright && values.legal_label &&
values.copyright === (config.copyright || "") &&
values.legal_label === (config.legal_label || "");
case "legals":
return values.hoster_name && values.hoster_address && values.hoster_contact &&
values.hoster_name === (config.hoster_name || "") &&
values.hoster_address === (config.hoster_address || "") &&
values.hoster_contact === (config.hoster_contact || "") &&
JSON.stringify(values.intellectual_property) === JSON.stringify(config.intellectual_property || []);
case "build":
return values.theme === (config.theme || "") &&
!!values.convert_images === !!config.convert_images &&
!!values.resize_images === !!config.resize_images;
default:
return true;
}
}
function isSectionComplete(section) {
const values = getSectionValues(section);
switch (section) {
case "info":
return (
values.title &&
values.subtitle &&
values.description &&
values.canonical &&
values.keywords.length > 0 &&
values.author
);
case "social":
return values.instagram_url && values.thumbnail;
case "menu":
return Array.isArray(values.items) && values.items.every(item => item.label && item.href);
case "footer":
return values.copyright && values.legal_label;
case "legals":
return (
values.hoster_name &&
values.hoster_address &&
values.hoster_contact &&
Array.isArray(values.intellectual_property) &&
values.intellectual_property.length > 0 &&
values.intellectual_property.every(ip => ip.paragraph)
);
case "build":
return !!values.theme;
default:
return true;
}
}
function updateSectionStatus(section) {
const statusEl = document.querySelector(`#${section}-section .section-status`);
if (!statusEl) return;
if (!isSectionComplete(section)) {
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
return;
}
if (isSectionSaved(section)) {
statusEl.innerHTML = "";
statusEl.style.display = "none";
} else {
statusEl.innerHTML = "⚠️ Section not yet saved";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
}
}
// --- Listen for changes in each section ---
Object.entries(forms).forEach(([section, form]) => {
if (!form) return;
form.addEventListener("input", () => updateSectionStatus(section));
form.addEventListener("change", () => updateSectionStatus(section));
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!form.reportValidity()) {
showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section);
return;
}
if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) {
showToast("❌ Thumbnail is required.", "error");
updateSectionStatus(section);
return;
}
if (section === "menu") {
updateMenuItemsFromInputs();
if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
showToast("❌ Please fill all menu item fields.", "error");
updateSectionStatus(section);
return;
}
}
if (section === "legals") {
updateIpParagraphsFromInputs();
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
showToast("❌ Please fill all intellectual property paragraphs.", "error");
updateSectionStatus(section);
return;
}
}
let payload = {};
payload[section] = getSectionValues(section);
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" },
@@ -447,10 +519,16 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
const result = await res.json(); const result = await res.json();
if (result.status === "ok") { if (result.status === "ok") {
showToast("✅ Site info saved!", "success"); showToast("✅ Section saved!", "success");
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
loadedConfig = data;
updateSectionStatus(section);
});
} else { } else {
showToast("❌ Error saving site info", "error"); showToast("❌ Error saving section", "error");
} }
}); });
} });
}); });
+207 -66
View File
@@ -70,9 +70,11 @@ function setupColorPicker(colorId, btnId, textId, initial) {
function setFontDropdown(selectId, value, options) { function setFontDropdown(selectId, value, options) {
const select = document.getElementById(selectId); const select = document.getElementById(selectId);
if (!select) return; if (!select) return;
select.innerHTML = options.map(opt => select.innerHTML = options.map(opt => {
`<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>` // Remove extension if present
).join(""); const base = opt.replace(/\.(woff2?|ttf|otf)$/, "");
return `<option value="${base}"${base === value ? " selected" : ""}>${base}</option>`;
}).join("");
} }
function setFallbackDropdown(selectId, value) { function setFallbackDropdown(selectId, value) {
@@ -116,54 +118,173 @@ function renderLocalFonts(fonts) {
}); });
} }
// --- Section helpers ---
function getSectionValues(section) {
switch (section) {
case "colors":
return {
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
};
case "google-fonts":
const googleFontsFields = document.getElementById("google-fonts-fields");
const fonts = [];
if (googleFontsFields) {
googleFontsFields.querySelectorAll(".input-field").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);
if (family) fonts.push({ family, weights });
});
}
return fonts;
case "fonts":
return {
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
}
};
case "favicon":
return {
path: document.getElementById("favicon-path").value
};
default:
return {};
}
}
function isSectionComplete(section) {
switch (section) {
case "colors":
const v = getSectionValues("colors");
return (
v.primary &&
v.primary_dark &&
v.secondary &&
v.accent &&
v.text_dark &&
v.background &&
v.browser_color
);
case "google-fonts":
const fonts = getSectionValues("google-fonts");
return fonts.every(f => f.family);
case "fonts":
const f = getSectionValues("fonts");
return f.primary.name && f.primary.fallback && f.secondary.name && f.secondary.fallback;
case "favicon":
const fav = getSectionValues("favicon");
return !!fav.path;
default:
return true;
}
}
function isSectionSaved(section, loadedConfig) {
switch (section) {
case "colors":
const v = getSectionValues("colors");
const c = loadedConfig.colors || {};
return (
v.primary === c.primary &&
v.primary_dark === c.primary_dark &&
v.secondary === c.secondary &&
v.accent === c.accent &&
v.text_dark === c.text_dark &&
v.background === c.background &&
v.browser_color === c.browser_color
);
case "google-fonts":
const fonts = getSectionValues("google-fonts");
const cf = loadedConfig.google_fonts || [];
return JSON.stringify(fonts) === JSON.stringify(cf);
case "fonts":
const f = getSectionValues("fonts");
const cfnt = loadedConfig.fonts || {};
return (
f.primary.name === (cfnt.primary?.name || "") &&
f.primary.fallback === (cfnt.primary?.fallback || "") &&
f.secondary.name === (cfnt.secondary?.name || "") &&
f.secondary.fallback === (cfnt.secondary?.fallback || "")
);
case "favicon":
const fav = getSectionValues("favicon");
const cfav = loadedConfig.favicon || {};
return fav.path === (cfav.path || "");
default:
return true;
}
}
function updateSectionStatus(section, loadedConfig) {
const statusEl = document.querySelector(`#${section}-form .section-status`);
if (!statusEl) return;
if (!isSectionComplete(section)) {
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
return;
}
if (isSectionSaved(section, loadedConfig)) {
statusEl.innerHTML = "";
statusEl.style.display = "none";
} else {
statusEl.innerHTML = "⚠️ Section not yet saved";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
}
}
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
const themeInfo = await fetchThemeInfo(); const themeInfo = await fetchThemeInfo();
const themeNameSpan = document.getElementById("current-theme"); const themeNameSpan = document.getElementById("current-theme");
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name; if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
const themeYaml = themeInfo.theme_yaml; let loadedConfig = themeInfo.theme_yaml;
const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : []; let googleFonts = loadedConfig.google_fonts ? JSON.parse(JSON.stringify(loadedConfig.google_fonts)) : [];
let localFonts = await fetchLocalFonts(themeInfo.theme_name); let localFonts = await fetchLocalFonts(themeInfo.theme_name);
// Colors // Colors
if (themeYaml.colors) { if (loadedConfig.colors) {
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1"); setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", loadedConfig.colors.primary || "#0065a1");
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384"); setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", loadedConfig.colors.primary_dark || "#005384");
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0"); setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", loadedConfig.colors.secondary || "#00b0f0");
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700"); setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", loadedConfig.colors.accent || "#ffc700");
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161"); setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", loadedConfig.colors.text_dark || "#616161");
setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff"); setupColorPicker("color-background", "color-background-btn", "color-background-text", loadedConfig.colors.background || "#fff");
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff"); setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", loadedConfig.colors.browser_color || "#fff");
} }
// Fonts // Fonts
function refreshFontDropdowns() { function refreshFontDropdowns() {
setFontDropdown("font-primary", document.getElementById("font-primary").value, [ setFontDropdown("font-primary", loadedConfig.fonts?.primary?.name || "Lato", [
...googleFonts.map(f => f.family), ...googleFonts.map(f => f.family),
...localFonts ...localFonts
]); ]);
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [ setFontDropdown("font-secondary", loadedConfig.fonts?.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family), ...googleFonts.map(f => f.family),
...localFonts ...localFonts
]); ]);
setFallbackDropdown("font-primary-fallback", loadedConfig.fonts?.primary?.fallback || "sans-serif");
setFallbackDropdown("font-secondary-fallback", loadedConfig.fonts?.secondary?.fallback || "serif");
} }
if (themeYaml.fonts) { refreshFontDropdowns();
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 // Font upload logic
const fontUploadInput = document.getElementById("font-upload"); const fontUploadInput = document.getElementById("font-upload");
const chooseFontBtn = document.getElementById("choose-font-btn"); const chooseFontBtn = document.getElementById("choose-font-btn");
const fontUploadStatus = document.getElementById("font-upload-status");
const localFontsList = document.getElementById("local-fonts-list"); const localFontsList = document.getElementById("local-fonts-list");
// Modal logic for font deletion // Modal logic for font deletion
@@ -341,9 +462,9 @@ document.addEventListener("DOMContentLoaded", async () => {
}; };
} }
if (themeYaml.favicon && themeYaml.favicon.path) { if (loadedConfig.favicon && loadedConfig.favicon.path) {
faviconInput.value = themeYaml.favicon.path; faviconInput.value = loadedConfig.favicon.path;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`); updateFaviconPreview(`/themes/${themeInfo.theme_name}/${loadedConfig.favicon.path}?t=${Date.now()}`);
} else { } else {
updateFaviconPreview(""); updateFaviconPreview("");
} }
@@ -367,6 +488,7 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts); googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts); renderGoogleFonts(googleFonts);
refreshFontDropdowns(); refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
}); });
} }
@@ -396,6 +518,7 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts); googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts); renderGoogleFonts(googleFonts);
refreshFontDropdowns(); refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
} }
}, true); }, true);
@@ -414,54 +537,72 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts); googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts); renderGoogleFonts(googleFonts);
refreshFontDropdowns(); refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
} }
}); });
} }
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => { // --- Section status listeners ---
[
{ form: document.getElementById("colors-form"), section: "colors" },
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
{ form: document.getElementById("fonts-form"), section: "fonts" },
{ form: document.getElementById("favicon-form"), section: "favicon" }
].forEach(({ form, section }) => {
if (!form) return;
form.addEventListener("input", () => updateSectionStatus(section, loadedConfig));
form.addEventListener("change", () => updateSectionStatus(section, loadedConfig));
});
// --- Section save handlers ---
[
{ form: document.getElementById("colors-form"), section: "colors" },
{ form: document.getElementById("google-fonts-form"), section: "google-fonts" },
{ form: document.getElementById("fonts-form"), section: "fonts" },
{ form: document.getElementById("favicon-form"), section: "favicon" }
].forEach(({ form, section }) => {
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
showLoader("Saving theme..."); if (!form.reportValidity() || !isSectionComplete(section)) {
const data = {}; showToast("❌ Please fill all required fields before saving.", "error");
data.colors = { updateSectionStatus(section, loadedConfig);
primary: document.getElementById("color-primary-text").value, return;
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
} }
}; // Merge with loadedConfig to avoid overwriting other sections
data.favicon = { let payload = { ...loadedConfig };
path: faviconInput.value switch (section) {
}; case "colors":
data.google_fonts = []; payload.colors = getSectionValues("colors");
document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => { break;
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value; case "google-fonts":
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value payload.google_fonts = getSectionValues("google-fonts");
.split(",").map(w => w.trim()).filter(w => w); break;
if (family) data.google_fonts.push({ family, weights }); case "fonts":
}); payload.fonts = getSectionValues("fonts");
break;
case "favicon":
payload.favicon = getSectionValues("favicon");
break;
}
showLoader("Saving...");
const res = await fetch("/api/theme-info", { const res = await fetch("/api/theme-info", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data }) body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: payload })
}); });
hideLoader(); hideLoader();
if (res.ok) { if (res.ok) {
showToast("✅ Theme saved!", "success"); showToast("✅ Section saved!", "success");
const updatedThemeInfo = await fetchThemeInfo();
loadedConfig = updatedThemeInfo.theme_yaml;
updateSectionStatus(section, loadedConfig);
} else { } else {
showToast("Error saving theme.", "error"); showToast("Error saving section.", "error");
} }
}); });
}); });
// Initial status update
["colors", "google-fonts", "fonts", "favicon"].forEach(section => updateSectionStatus(section, loadedConfig));
});
+17 -34
View File
@@ -11,54 +11,37 @@ function hideLoader() {
if (loader) loader.classList.remove("active"); if (loader) loader.classList.remove("active");
} }
// --- Upload gallery images --- // --- Generic upload handler ---
const galleryInput = document.getElementById('upload-gallery'); function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) {
if (galleryInput) { const input = document.getElementById(inputId);
galleryInput.addEventListener('change', async (e) => { if (!input) return;
input.addEventListener('change', async (e) => {
const files = e.target.files; const files = e.target.files;
if (!files.length) return; if (!files.length) return;
showLoader("Uploading photos..."); showLoader(loaderText);
const formData = new FormData(); const formData = new FormData();
for (const file of files) formData.append('files', file); for (const file of files) formData.append('files', file);
try { try {
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData }); const res = await fetch(apiUrl, { method: 'POST', body: formData });
const data = await res.json(); const data = await res.json();
hideLoader(); hideLoader();
if (res.ok) { if (res.ok) {
showToast(`${data.uploaded.length} gallery image(s) uploaded!`, "success"); showToast(`${data.uploaded.length} ${successMsg}`, "success");
if (typeof refreshGallery === "function") refreshGallery(); if (typeof refreshFn === "function") refreshFn();
} else showToast('Error: ' + data.error, "error"); } else showToast('Error: ' + data.error, "error");
} catch(err) { } catch(err) {
hideLoader(); hideLoader();
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 --- // --- Setup all upload inputs ---
const heroInput = document.getElementById('upload-hero'); setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
if (heroInput) { setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
heroInput.addEventListener('change', async (e) => { setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
const files = e.target.files; setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
if (!files.length) return;
showLoader("Uploading hero photos...");
const formData = new FormData();
for (const file of files) formData.append('files', file);
try {
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
const data = await res.json();
hideLoader();
if (res.ok) {
showToast(`${data.uploaded.length} hero image(s) uploaded!`, "success");
if (typeof refreshHero === "function") refreshHero();
} else showToast('Error: ' + data.error, "error");
} catch(err) {
hideLoader();
console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; }
});
}
+40 -12
View File
@@ -5,10 +5,12 @@
{% block content %} {% block content %}
<h1>Edit Site Info</h1> <h1>Edit Site Info</h1>
<form id="site-info-form">
<!-- Info Section --> <!-- Info Section -->
<fieldset> <form id="info-form" autocomplete="off">
<fieldset id="info-section">
<h2>Info</h2> <h2>Info</h2>
<p class="section-status"></p>
<p>Set the basic information for your site and SEO</p> <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">
@@ -36,10 +38,15 @@
<input type="text" name="info.author" placeholder="Your Name" required> <input type="text" name="info.author" placeholder="Your Name" required>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="info">Save</button>
</fieldset> </fieldset>
</form>
<!-- Social Section --> <!-- Social Section -->
<fieldset> <form id="social-form" autocomplete="off">
<fieldset id="social-section">
<h2>Social</h2> <h2>Social</h2>
<p class="section-status"></p>
<p>Set your social media links and thumbnail for link sharing</p> <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">
@@ -49,16 +56,21 @@
<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"> <input type="hidden" name="social.thumbnail" id="social-thumbnail" required>
<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>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="social">Save</button>
</fieldset> </fieldset>
</form>
<!-- Menu Section --> <!-- Menu Section -->
<fieldset> <form id="menu-form" autocomplete="off">
<fieldset id="menu-section">
<h2>Menu</h2> <h2>Menu</h2>
<p class="section-status"></p>
<p>Manage your site menu items. You can use tag combination to propose custom filters</p> <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%;">
@@ -66,10 +78,15 @@
<button type="button" id="add-menu-item">+ Add menu item</button> <button type="button" id="add-menu-item">+ Add menu item</button>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="menu">Save</button>
</fieldset> </fieldset>
</form>
<!-- Footer Section --> <!-- Footer Section -->
<fieldset> <form id="footer-form" autocomplete="off">
<fieldset id="footer-section">
<h2>Footer</h2> <h2>Footer</h2>
<p class="section-status"></p>
<p>Set your copyright informations and legal link name</p> <p>Set your copyright informations and legal link name</p>
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
@@ -78,13 +95,18 @@
</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" re> <input type="text" name="footer.legal_label" required>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="footer">Save</button>
</fieldset> </fieldset>
</form>
<!-- Legals Section --> <!-- Legals Section -->
<fieldset> <form id="legals-form" autocomplete="off">
<fieldset id="legals-section">
<h2>Legals</h2> <h2>Legals</h2>
<p class="section-status"></p>
<p>Set your legal informations</p> <p>Set your legal informations</p>
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
@@ -105,15 +127,20 @@
<button type="button" id="add-ip-paragraph">+ Add paragraph</button> <button type="button" id="add-ip-paragraph">+ Add paragraph</button>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="legals">Save</button>
</fieldset> </fieldset>
</form>
<!-- Build Section --> <!-- Build Section -->
<fieldset> <form id="build-form" autocomplete="off">
<fieldset id="build-section">
<h2>Build</h2> <h2>Build</h2>
<p class="section-status"></p>
<p>Select a theme from the dropdown menu or add your custom theme folder</p> <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" required></select> <select name="build.theme" id="theme-select"></select>
<button type="button" id="remove-theme-btn" class="remove-btn up-btn danger">🗑 Remove selected theme</button> <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>
@@ -129,9 +156,11 @@
</label> </label>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="build">Save</button>
</fieldset> </fieldset>
<button type="submit">Save</button>
</form> </form>
<!-- Stepper -->
<div class="section"> <div class="section">
<h2>Steps</h2> <h2>Steps</h2>
<p>Follow the steps to generate your static gallery</p> <p>Follow the steps to generate your static gallery</p>
@@ -145,7 +174,6 @@
<li><button id="stepper-build">Generate your static site!</button></li> <li><button id="stepper-build">Generate your static site!</button></li>
</ul> </ul>
</div> </div>
</div>
<!-- Delete thumbnail confirmation modal--> <!-- Delete thumbnail confirmation modal-->
<div class="content-inner"> <div class="content-inner">
<div id="delete-modal" class="modal" style="display:none;"> <div id="delete-modal" class="modal" style="display:none;">
+99 -37
View File
@@ -7,7 +7,7 @@ body {
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
margin:0px; margin:0px;
width: 100vw; min-width: 320px;
} }
a { a {
@@ -236,11 +236,32 @@ h2 {
cursor: pointer; cursor: pointer;
} }
/* --- Filter Row --- */
.filter-row label {
display: inline-block;
margin-right: 16px;
font-weight: bold;
cursor: pointer;
}
.filter-row input[type="radio"] {
accent-color: #55c3ec;
margin-right: 6px;
}
/* --- Gallery & Hero Grid --- */ /* --- Gallery & Hero Grid --- */
#gallery, #hero { #gallery, #hero {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px; gap: 15px;
margin: 30px 0 0 0;
}
#hero-count-bottom, #gallery-count-bottom {
flex-basis: 100%;
}
.bottom-action-row {
margin-top: 30px; margin-top: 30px;
} }
@@ -316,6 +337,20 @@ h2 {
.toast.success { background-color: #28a7468c; } .toast.success { background-color: #28a7468c; }
.toast.error { background-color: #dc3545; } .toast.error { background-color: #dc3545; }
/* img fade in */
.fade-in-img {
opacity: 0;
transform: scale(1.02);
transition: opacity 1.2s ease-out, transform 1.2s ease-out;
will-change: opacity, transform;
}
.fade-in-img.loaded {
opacity: 1;
transform: scale(1);
}
/* --- Tags --- */ /* --- Tags --- */
.tag-input { .tag-input {
display: flex; display: flex;
@@ -366,15 +401,18 @@ h2 {
z-index: 999; z-index: 999;
display: none; display: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.15); box-shadow: 0 4px 8px rgba(0,0,0,0.15);
padding: 8px;
border-radius: 10px;
} }
.tag-input ul.suggestions li { .tag-input ul.suggestions li {
padding: 6px 8px; padding: 6px 10px;
cursor: pointer; cursor: pointer;
} }
.tag-input ul.suggestions li:hover { .tag-input ul.suggestions li:hover {
background-color: #007782; background-color: #007782;
border-radius: 7px;
} }
.tags-display { .tags-display {
@@ -406,7 +444,9 @@ h2 {
background-color: #007782; background-color: #007782;
color: white; color: white;
cursor: pointer; cursor: pointer;
border-radius: 7px;
} }
.suggestions li { .suggestions li {
cursor: pointer; cursor: pointer;
} }
@@ -416,6 +456,14 @@ h2 {
display: flex; display: flex;
} }
.modal-actions-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically */
}
.flex-column { .flex-column {
flex-direction: column; flex-direction: column;
} }
@@ -521,18 +569,23 @@ h2 {
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap;
} }
/* --- Remove All Buttons --- */ /* --- Remove All Buttons --- */
#remove-all-hero, #remove-all-gallery { #remove-all-hero, #remove-all-gallery, .bottom-remove-btn {
background: #2d2d2d; background: #2d2d2d;
color: white; color: white;
display: none; display: none;
margin-bottom: 6px; margin-bottom: 6px;
} }
.bottom-remove-btn {
display: inherit;
}
#remove-all-gallery:hover, #remove-all-gallery:hover,
#remove-all-hero:hover { #remove-all-hero:hover, .bottom-remove-btn:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
@@ -541,6 +594,7 @@ h2 {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
} }
@@ -588,9 +642,9 @@ label {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
#site-info-form input, #theme-editor-form input, form input,
#site-info-form textarea, #theme-editor-form textarea, form textarea,
#site-info-form select, #theme-editor-form select { form select {
background: #1f2223; background: #1f2223;
color: #fff; color: #fff;
border: 1px solid #585858; border: 1px solid #585858;
@@ -612,27 +666,24 @@ label {
gap: 0 18px; gap: 0 18px;
} }
.theme-info {
margin-bottom: 25px;
}
form input::placeholder,
#site-info-form input::placeholder, form textarea::placeholder {
#theme-editor-form input::placeholder,
#site-info-form textarea::placeholder,
#theme-editor-form textarea::placeholder {
color: #585858; color: #585858;
font-style: italic; font-style: italic;
} }
#site-info-form input:focus, form input:focus,
#theme-editor-form input:focus, form textarea:focus,
#site-info-form textarea:focus, form select:focus {
#theme-editor-form textarea:focus,
#site-info-form select:focus,
#theme-editor-form select:focus {
border-color: #585858; border-color: #585858;
background: #161616; background: #161616;
} }
#site-info-form textarea,
#theme-editor-form textarea { form textarea {
min-height: 60px; min-height: 60px;
resize: vertical; resize: vertical;
} }
@@ -651,7 +702,7 @@ img#thumbnail-preview {
border: 1px solid #585858; border: 1px solid #585858;
} }
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] { form button[type="submit"] {
background: linear-gradient(135deg, #26c4ff, #016074); background: linear-gradient(135deg, #26c4ff, #016074);
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
@@ -659,17 +710,18 @@ img#thumbnail-preview {
border-radius: 30px; border-radius: 30px;
padding: 12px 32px; padding: 12px 32px;
font-size: 1.1em; font-size: 1.1em;
margin: 0 0 45px 0; margin: 16px 0 0 0;
cursor: pointer; cursor: pointer;
box-shadow: 0 4px 16px rgba(38,196,255,0.15); box-shadow: 0 4px 16px rgba(38,196,255,0.15);
transition: background 0.2s; transition: background 0.2s;
display: block;
} }
#site-info-form button[type="submit"]:hover, #theme-editor-form button[type="submit"]:hover { form button[type="submit"]:hover {
background: linear-gradient(135deg, #72d9ff, #26657e); background: linear-gradient(135deg, #72d9ff, #26657e);
} }
#site-info-form button[type="button"], #theme-editor-form button[type="button"] { form button[type="button"] {
background: #00000000; background: #00000000;
color: #fff; color: #fff;
border: none; border: none;
@@ -682,34 +734,36 @@ img#thumbnail-preview {
border: 1px solid #585858; border: 1px solid #585858;
} }
#site-info-form button[type="button"]:hover, #theme-editor-form button[type="button"]:hover { form button[type="button"]:hover{
background: #2d2d2d; background: #2d2d2d;
color: #fff; color: #fff;
} }
#site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph, #theme-editor-form button.remove-menu-item, #theme-editor-form button.remove-ip-paragraph { form button.remove-menu-item,
form button.remove-ip-paragraph {
margin-top: 0px; margin-top: 0px;
margin-bottom: 4px; margin-bottom: 4px;
border-radius: 30px; border-radius: 30px;
background: #2d2d2d; background: #2d2d2d;
} }
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover { form button.remove-menu-item:hover,
form button.remove-ip-paragraph:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
#site-info-form button.remove-btn, #theme-editor-form button.remove-btn { form button.remove-btn {
border-radius: 30px; border-radius: 30px;
background: #2d2d2d; background: #2d2d2d;
} }
#site-info-form button.remove-btn:hover, #theme-editor-form button.remove-btn:hover { form button.remove-btn:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label { form .thumbnail-form-label {
margin-top: 10px; margin-top: 10px;
} }
@@ -771,6 +825,10 @@ fieldset p, .section p {
margin-top: 16px; margin-top: 16px;
} }
.section-status {
font-style: normal;
}
/* --- Stepper --- */ /* --- Stepper --- */
#stepper { #stepper {
@@ -905,6 +963,7 @@ justify-content: center;
#footer a { #footer a {
color: #fff; color: #fff;
margin: auto;
} }
.footer-credit .lum-first::before { .footer-credit .lum-first::before {
@@ -920,6 +979,7 @@ justify-content: center;
width: 16px; width: 16px;
height: 16px; height: 16px;
display: flex; display: flex;
margin: auto;
} }
.icon-text { .icon-text {
@@ -994,12 +1054,12 @@ justify-content: center;
flex-direction: column; flex-direction: column;
} }
#stepper { form button[type="button"], form button[type="submit"], #stepper li {
flex-direction: column; width: 100%;
} }
#stepper li { #stepper {
width: 100%; flex-direction: column;
} }
.footer-container, .footer-links { .footer-container, .footer-links {
@@ -1020,14 +1080,16 @@ justify-content: center;
/* Hide the default arrow */ /* Hide the default arrow */
color: transparent; color: transparent;
} }
#site-info-form, #theme-editor-form {
padding: 18px 8px;
}
.input-field { .input-field {
min-width: 100%; min-width: 100%;
margin-bottom: 12px; margin-bottom: 12px;
} }
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
width: 100%;
}
#color-picker .input-field{ #color-picker .input-field{
min-width: 170px; min-width: 170px;
} }
+5 -2
View File
@@ -48,7 +48,10 @@
<span id="build-success-modal-close" class="modal-close">&times;</span> <span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3> <h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p> <p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button> <div class="modal-actions-row">
<button id="preview-site-btn" class="modal-btn" data-preview-port="{{ preview_port }}">🔎 Preview Site</button>
<button id="download-zip-btn" class="modal-btn">📦 Download ZIP</button>
</div>
<div id="zip-loader" style="display:none;">Creating ZIP...</div> <div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div> </div>
</div> </div>
@@ -65,7 +68,7 @@
</div> </div>
<div class="footer-links"> <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 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 gitea" href="https://git.djeex.fr/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> <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>
+40 -23
View File
@@ -9,116 +9,133 @@
<div class="theme-info"> <div class="theme-info">
<strong>Current theme:</strong> <span id="current-theme"></span> <strong>Current theme:</strong> <span id="current-theme"></span>
</div> </div>
<form id="theme-editor-form"> <div id="theme-editor-form">
<!-- Colors Section --> <!-- Colors Section -->
<form id="colors-form" autocomplete="off">
<fieldset id="color-picker"> <fieldset id="color-picker">
<h2>Colors</h2> <h2>Colors</h2>
<p class="section-status"></p>
<p>Set the color values for your theme</p> <p>Set the color values for your theme</p>
<div class="fields"> <div class="fields">
<!-- Example for one color field, repeat for all -->
<div class="input-field"> <div class="input-field">
<label>Primary</label> <label>Primary</label>
<div class="fields color-fields"> <div class="fields color-fields">
<button type="button" id="color-primary-btn" class="color-btn"></button> <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="color" name="colors.primary" id="color-primary" class="color-input" required>
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;"> <input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;" required>
</div> </div>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Primary Dark</label> <label>Primary Dark</label>
<div class="fields color-fields"> <div class="fields color-fields">
<button type="button" id="color-primary-dark-btn" class="color-btn"></button> <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="color" name="colors.primary_dark" id="color-primary-dark" class="color-input" required>
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;"> <input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;" required>
</div> </div>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Secondary</label> <label>Secondary</label>
<div class="fields color-fields"> <div class="fields color-fields">
<button type="button" id="color-secondary-btn" class="color-btn"></button> <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="color" name="colors.secondary" id="color-secondary" class="color-input" required>
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;"> <input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;" required>
</div> </div>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Accent</label> <label>Accent</label>
<div class="fields color-fields"> <div class="fields color-fields">
<button type="button" id="color-accent-btn" class="color-btn"></button> <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="color" name="colors.accent" id="color-accent" class="color-input" required>
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;"> <input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;" required>
</div> </div>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Text Dark</label> <label>Text Dark</label>
<div class="fields color-fields"> <div class="fields color-fields">
<button type="button" id="color-text-dark-btn" class="color-btn"></button> <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="color" name="colors.text_dark" id="color-text-dark" class="color-input" required>
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;"> <input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;" required>
</div> </div>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Background</label> <label>Background</label>
<div class="fields color-fields"> <div class="fields color-fields">
<button type="button" id="color-background-btn" class="color-btn"></button> <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="color" name="colors.background" id="color-background" class="color-input" required>
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;"> <input type="text" name="colors.background_text" id="color-background-text" style="width:100px;" required>
</div> </div>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Browser Color</label> <label>Browser Color</label>
<div class="fields color-fields"> <div class="fields color-fields">
<button type="button" id="color-browser-color-btn" class="color-btn"></button> <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="color" name="colors.browser_color" id="color-browser-color" class="color-input" required>
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;"> <input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;" required>
</div> </div>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="colors">Save</button>
</fieldset> </fieldset>
</form>
<!-- Google Fonts Section --> <!-- Google Fonts Section -->
<form id="google-fonts-form" autocomplete="off">
<fieldset> <fieldset>
<h2>Google Fonts</h2> <h2>Google Fonts</h2>
<p class="section-status"></p>
<p>Add Google Fonts to your theme</p> <p>Add Google Fonts to your theme</p>
<div class="fields" id="google-fonts-fields"> <div class="fields" id="google-fonts-fields">
<!-- JS will render font family and weights inputs here --> <!-- JS will render font family and weights inputs here -->
</div> </div>
<button type="button" id="add-google-font">Add Google Font</button> <button type="button" id="add-google-font">Add Google Font</button>
<button type="submit" class="section-save-btn" data-section="google-fonts">Save</button>
</fieldset> </fieldset>
</form>
<!-- Custom Font Upload Section --> <!-- Custom Font Upload Section -->
<form id="font-upload-form" autocomplete="off">
<fieldset> <fieldset>
<h2>Upload Custom Font</h2> <h2>Upload Custom Font</h2>
<p class="section-status"></p>
<p>Supported formats: .woff, .woff2</p> <p>Supported formats: .woff, .woff2</p>
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;"> <input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
<div id="local-fonts-list" class="font-list"></div> <div id="local-fonts-list" class="font-list"></div>
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button> <button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
<!-- Save button not needed for upload-only section -->
</fieldset> </fieldset>
</form>
<!-- Fonts Section --> <!-- Fonts Section -->
<form id="fonts-form" autocomplete="off">
<fieldset> <fieldset>
<h2>Fonts</h2> <h2>Fonts</h2>
<p class="section-status"></p>
<p>Select where to apply your fonts</p> <p>Select where to apply your fonts</p>
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
<label>Primary Font</label> <label>Primary Font</label>
<select name="fonts.primary.name" id="font-primary"></select> <select name="fonts.primary.name" id="font-primary" required></select>
<label>Fallback</label> <label>Fallback</label>
<select name="fonts.primary.fallback" id="font-primary-fallback"> <select name="fonts.primary.fallback" id="font-primary-fallback" required>
<option value="sans-serif">sans-serif</option> <option value="sans-serif">sans-serif</option>
<option value="serif">serif</option> <option value="serif">serif</option>
</select> </select>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Secondary Font</label> <label>Secondary Font</label>
<select name="fonts.secondary.name" id="font-secondary"></select> <select name="fonts.secondary.name" id="font-secondary" required></select>
<label>Fallback</label> <label>Fallback</label>
<select name="fonts.secondary.fallback" id="font-secondary-fallback"> <select name="fonts.secondary.fallback" id="font-secondary-fallback" required>
<option value="sans-serif">sans-serif</option> <option value="sans-serif">sans-serif</option>
<option value="serif">serif</option> <option value="serif">serif</option>
</select> </select>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="fonts">Save</button>
</fieldset> </fieldset>
</form>
<!-- Favicon Section --> <!-- Favicon Section -->
<form id="favicon-form" autocomplete="off">
<fieldset> <fieldset>
<h2>Favicon</h2> <h2>Favicon</h2>
<p class="section-status"></p>
<p>Supported formats: .png, .jpg, .jpeg</p> <p>Supported formats: .png, .jpg, .jpeg</p>
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
@@ -132,9 +149,11 @@
</div> </div>
</div> </div>
</div> </div>
<button type="submit" class="section-save-btn" data-section="favicon">Save</button>
</fieldset> </fieldset>
<button type="submit">Save Theme</button>
</form> </form>
</div>
<!-- Stepper -->
<div class="section"> <div class="section">
<h2>Steps</h2> <h2>Steps</h2>
<p>Follow the steps to generate your static gallery</p> <p>Follow the steps to generate your static gallery</p>
@@ -148,8 +167,6 @@
<li><button id="stepper-build">Generate your static site!</button></li> <li><button id="stepper-build">Generate your static site!</button></li>
</ul> </ul>
</div> </div>
</div>
</div>
<!-- Delete confirmation modal for favicon --> <!-- Delete confirmation modal for favicon -->
<div id="delete-favicon-modal" class="modal" style="display:none;"> <div id="delete-favicon-modal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">