Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc5b1c4234 | ||
|
|
2968e875a7 | ||
|
|
a7ce49b989 | ||
|
|
26e3ca684b | ||
|
|
a938234495 | ||
|
|
193b1704f8 | ||
|
|
9d56d729c1 | ||
|
|
40a01c4701 | ||
|
|
28e3b087b0 | ||
|
|
116a9574ce | ||
|
|
6c7216c0e1 | ||
|
|
7c98a6ceaf | ||
|
|
69d303c7a1 | ||
|
|
1d1d7d8304 | ||
|
|
205dcae2bc | ||
|
|
b7fdcacf77 | ||
|
|
9cdf6bbd32 | ||
|
|
f92060603d | ||
|
|
54e9281793 | ||
|
|
5fb6ef18d1 | ||
|
|
1119647884 | ||
|
|
2cc0a213c9 | ||
|
|
24113a4aa8 | ||
|
|
be8ce4f62c | ||
|
|
edca473e29 | ||
|
|
eac90820d2 | ||
|
|
191fa82711 | ||
|
|
b17652e471 | ||
|
|
65fd62e342 | ||
|
|
6b03ee30fa | ||
|
|
fd45ebbd53 | ||
|
|
795f5fbd13 | ||
|
|
f8bebb9c95 | ||
|
|
3198755576 | ||
|
|
f98f2d598f | ||
|
|
e8718e71ab | ||
|
|
5b65e5efe3 | ||
|
|
021e0c7974 | ||
|
|
debbf07280 | ||
|
|
df96782500 | ||
|
|
febf4d2be5 | ||
|
|
617545e1bb | ||
|
|
922ce99679 | ||
|
|
cd0428990a | ||
|
|
d3af86be8c |
@@ -1,4 +1,5 @@
|
||||
.*
|
||||
!.env
|
||||
!.sh
|
||||
!.gitignore
|
||||
output/
|
||||
|
||||
+11
-6
@@ -1,10 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.13-alpine AS builder
|
||||
|
||||
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 ./src/ ./src/
|
||||
@@ -12,7 +20,4 @@ COPY ./config /app/default
|
||||
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
|
||||
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
@@ -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.
|
||||
|
||||
👉 __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:
|
||||
|
||||
- **Modern** — [View Demo](https://modern.djeex.fr)
|
||||
|
||||
+7
-10
@@ -1,4 +1,3 @@
|
||||
# Please change this by your settings.
|
||||
info:
|
||||
title:
|
||||
subtitle:
|
||||
@@ -9,7 +8,7 @@ info:
|
||||
|
||||
social:
|
||||
instagram_url:
|
||||
thumbnail:
|
||||
thumbnail: ''
|
||||
|
||||
menu:
|
||||
items:
|
||||
@@ -18,19 +17,17 @@ menu:
|
||||
|
||||
footer:
|
||||
copyright: Copyright © 2025
|
||||
legal_link: '/legals/'
|
||||
legal_link: /legals/
|
||||
legal_label: Legal notice
|
||||
|
||||
# Build parameters
|
||||
build:
|
||||
theme: modern # choose a theme in config/theme folder
|
||||
convert_images: true # true to enable image conversion
|
||||
resize_images: true # true to enable image resizing
|
||||
theme: modern
|
||||
convert_images: true
|
||||
resize_images: true
|
||||
|
||||
# Change this by your legals
|
||||
legals:
|
||||
hoster_name:
|
||||
hoster_adress:
|
||||
hoster_address:
|
||||
hoster_contact:
|
||||
intellectual_property:
|
||||
- paragraph: ""
|
||||
- paragraph: ''
|
||||
|
||||
@@ -35,16 +35,13 @@ img, tag {
|
||||
|
||||
#footer {
|
||||
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
#footer {
|
||||
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
|
||||
border-radius: 15px 15px 0 0;
|
||||
max-width: 1140px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@@ -53,7 +50,7 @@ img, tag {
|
||||
|
||||
.hero-background {
|
||||
max-width: 90%;
|
||||
margin: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
#-----------------------------------#
|
||||
# Modern theme for Lumeex #
|
||||
# https://git.djeex.fr/Djeex/lumeex #
|
||||
#-----------------------------------#
|
||||
colors:
|
||||
primary: '#0065a1'
|
||||
primary: '#0065A1'
|
||||
primary_dark: '#005384'
|
||||
secondary: '#00b0f0'
|
||||
accent: '#ffc700'
|
||||
secondary: '#00B0F0'
|
||||
accent: '#FFC700'
|
||||
text_dark: '#616161'
|
||||
background: '#ffffff'
|
||||
browser_color: '#ffffff'
|
||||
background: '#FFFFFF'
|
||||
browser_color: '#FFFFFF'
|
||||
|
||||
favicon:
|
||||
path: favicon.png
|
||||
|
||||
fonts:
|
||||
primary:
|
||||
fallback: sans-serif
|
||||
name: Lato
|
||||
secondary:
|
||||
fallback: serif
|
||||
name: Montserrat
|
||||
|
||||
google_fonts:
|
||||
- family: Lato
|
||||
- family: 'Lato'
|
||||
weights:
|
||||
- '200'
|
||||
- '400'
|
||||
@@ -23,10 +29,3 @@ google_fonts:
|
||||
- '200'
|
||||
- '400'
|
||||
- '700'
|
||||
fonts:
|
||||
primary:
|
||||
name: Lato
|
||||
fallback: sans-serif
|
||||
secondary:
|
||||
name: Montserrat
|
||||
fallback: serif
|
||||
@@ -1,21 +1,17 @@
|
||||
#-----------------------------------#
|
||||
# Typewriter theme for Lumeex #
|
||||
# https://git.djeex.fr/Djeex/lumeex #
|
||||
#-----------------------------------#
|
||||
colors:
|
||||
primary: '#0065a1'
|
||||
accent: '#FFC700'
|
||||
background: '#FFFFFF'
|
||||
browser_color: '#FFFFFF'
|
||||
primary: '#0065A1'
|
||||
primary_dark: '#005384'
|
||||
secondary: '#00b0f0'
|
||||
accent: '#ffc700'
|
||||
secondary: '#00B0F0'
|
||||
text_dark: '#616161'
|
||||
background: '#ffffff'
|
||||
browser_color: '#ffffff'
|
||||
favicon:
|
||||
path: favicon.png
|
||||
fonts:
|
||||
primary:
|
||||
name: Trixie
|
||||
name: trixie
|
||||
fallback: sans-serif
|
||||
secondary:
|
||||
name: Trixie
|
||||
name: trixie
|
||||
fallback: serif
|
||||
@@ -1,19 +1,25 @@
|
||||
#-----------------------------------#
|
||||
# Modern theme for Lumeex #
|
||||
# https://git.djeex.fr/Djeex/lumeex #
|
||||
#-----------------------------------#
|
||||
colors:
|
||||
primary: '#0065a1'
|
||||
primary: '#0065A1'
|
||||
primary_dark: '#005384'
|
||||
secondary: '#00b0f0'
|
||||
accent: '#ffc700'
|
||||
secondary: '#00B0F0'
|
||||
accent: '#FFC700'
|
||||
text_dark: '#616161'
|
||||
background: '#fff'
|
||||
browser_color: '#fff'
|
||||
background: '#FFFFFF'
|
||||
browser_color: '#FFFFFF'
|
||||
|
||||
favicon:
|
||||
path: favicon.png
|
||||
|
||||
fonts:
|
||||
primary:
|
||||
fallback: sans-serif
|
||||
name: Lato
|
||||
secondary:
|
||||
fallback: serif
|
||||
name: Montserrat
|
||||
|
||||
google_fonts:
|
||||
- family: Lato
|
||||
- family: ''
|
||||
weights:
|
||||
- '200'
|
||||
- '400'
|
||||
@@ -23,10 +29,3 @@ google_fonts:
|
||||
- '200'
|
||||
- '400'
|
||||
- '700'
|
||||
fonts:
|
||||
primary:
|
||||
name: Lato
|
||||
fallback: sans-serif
|
||||
secondary:
|
||||
name: Montserrat
|
||||
fallback: serif
|
||||
@@ -1,21 +1,19 @@
|
||||
#-----------------------------------#
|
||||
# Typewriter theme for Lumeex #
|
||||
# https://git.djeex.fr/Djeex/lumeex #
|
||||
#-----------------------------------#
|
||||
colors:
|
||||
primary: '#0065a1'
|
||||
accent: '#FFC700'
|
||||
background: '#FFFFFF'
|
||||
browser_color: '#FFFFFF'
|
||||
primary: '#0065A1'
|
||||
primary_dark: '#005384'
|
||||
secondary: '#00b0f0'
|
||||
accent: '#ffc700'
|
||||
secondary: '#00B0F0'
|
||||
text_dark: '#616161'
|
||||
background: '#fff'
|
||||
browser_color: '#fff'
|
||||
|
||||
favicon:
|
||||
path: favicon.png
|
||||
|
||||
fonts:
|
||||
primary:
|
||||
name: Trixie
|
||||
name: trixie.woff
|
||||
fallback: sans-serif
|
||||
secondary:
|
||||
name: Trixie
|
||||
name: trixie.woff
|
||||
fallback: serif
|
||||
@@ -0,0 +1,2 @@
|
||||
PREVIEW_PORT=3000
|
||||
WEBUI_PORT=5000
|
||||
+23
-18
@@ -1,33 +1,36 @@
|
||||
#!/bin/bash
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
CYAN="\033[1;36m"
|
||||
NC="\033[0m"
|
||||
|
||||
copy_default_config() {
|
||||
echo "Checking configuration directory..."
|
||||
if [ ! -d "/app/config" ]; then
|
||||
echo "[~] Checking configuration directory..."
|
||||
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
|
||||
|
||||
for file in /app/default/*; do
|
||||
filename=$(basename "$file")
|
||||
target="/app/config/$filename"
|
||||
|
||||
for src in $(find /app/default -mindepth 1); do
|
||||
relpath="${src#/app/default/}"
|
||||
target="/app/config/$relpath"
|
||||
if [ ! -e "$target" ]; then
|
||||
echo "Copying default config file: $filename"
|
||||
cp -r "$file" "$target"
|
||||
echo "[→] Copying: $relpath"
|
||||
if [ -d "$src" ]; then
|
||||
mkdir -p "$target"
|
||||
cp -r "$src/" "$target/"
|
||||
else
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp "$src" "$target"
|
||||
fi
|
||||
files_copied=true
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$files_copied" = true ]; then
|
||||
echo "Default configuration files copied successfully."
|
||||
echo "[✓] Default configuration files/folders copied successfully."
|
||||
else
|
||||
echo "No default files needed to be copied."
|
||||
echo "[✓] No default files/folders needed to be copied."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -43,11 +46,13 @@ start_server() {
|
||||
cat /tmp/build_logs_fifo >&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 &
|
||||
SERVER_PID=$!
|
||||
|
||||
echo "Starting Lumeex Flask webui..."
|
||||
echo "[~] Starting Lumeex Flask webui..."
|
||||
python3 -u -m src.py.webui.webui &
|
||||
WEBUI_PID=$!
|
||||
|
||||
@@ -72,15 +77,15 @@ fi
|
||||
|
||||
case "$1" in
|
||||
build)
|
||||
echo "Running build.py..."
|
||||
echo "[~] Running build.py..."
|
||||
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
|
||||
;;
|
||||
gallery)
|
||||
echo "Running gallery.py..."
|
||||
echo "[~] Running gallery.py..."
|
||||
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $1"
|
||||
echo "[!] Unknown command: $1"
|
||||
exec "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -2,10 +2,15 @@ services:
|
||||
lumeex:
|
||||
container_name: lmx
|
||||
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:
|
||||
- ../config:/app/config # mount config directory
|
||||
- ../output:/app/output # mount output directory
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "5000:5000"
|
||||
- "${PREVIEW_PORT:-3000}:3000"
|
||||
- "${WEBUI_PORT:-5000}:5000"
|
||||
|
||||
@@ -9,7 +9,6 @@ window.addEventListener("DOMContentLoaded", () => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target;
|
||||
console.log("Lazy-loading image:", img.dataset.src);
|
||||
img.src = img.dataset.src;
|
||||
img.onload = () => {
|
||||
img.classList.add("loaded");
|
||||
@@ -33,6 +32,14 @@ window.addEventListener("DOMContentLoaded", () => {
|
||||
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) {
|
||||
onLoad(); // already loaded
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
|
||||
// Fade in effect for elements with class 'appear'
|
||||
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 io = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
@@ -32,6 +39,7 @@ const randomizeHeroBackground = () => {
|
||||
if (images.length === 0) return;
|
||||
let currentIndex = Math.floor(Math.random() * images.length);
|
||||
heroBg.style.backgroundImage = `url(/img/${images[currentIndex]})`;
|
||||
if (images.length < 2) return; // <-- Prevent interval if only one image
|
||||
setInterval(() => {
|
||||
let nextIndex;
|
||||
do {
|
||||
|
||||
+22
-36
@@ -78,6 +78,8 @@ html,body {
|
||||
font-weight: 400;
|
||||
line-height:1.5;
|
||||
color:var(--color-primary-dark);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
html {
|
||||
@@ -179,8 +181,8 @@ h2 {
|
||||
/* animation */
|
||||
|
||||
.appear {
|
||||
-webkit-transition: all 0.3s;
|
||||
transition: all 0.3s;
|
||||
-webkit-transition: all 1s;
|
||||
transition: all 1s;
|
||||
opacity: 0;
|
||||
-webkit-transform: translateY(20px);
|
||||
transform: translateY(20px);
|
||||
@@ -192,36 +194,6 @@ h2 {
|
||||
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 */
|
||||
|
||||
.fade-in-img {
|
||||
@@ -267,12 +239,23 @@ h2 {
|
||||
/* Hero */
|
||||
|
||||
#hero {
|
||||
min-height: 100vh;
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#hero .section {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#hero .content-wrapper, #hero .section {
|
||||
height:100%;
|
||||
}
|
||||
|
||||
.hero-background {
|
||||
height: 66%;
|
||||
width: 100%;
|
||||
@@ -333,7 +316,6 @@ h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
.gallery {
|
||||
padding-top: 15px;
|
||||
}
|
||||
@@ -371,6 +353,11 @@ h2 {
|
||||
|
||||
/* Footer */
|
||||
|
||||
#footer {
|
||||
margin: auto 0 0 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
text-align: center;
|
||||
padding-top: 40px;
|
||||
@@ -491,7 +478,7 @@ h2 {
|
||||
}
|
||||
|
||||
.section img {
|
||||
margin: 0px 0 60px 0;
|
||||
margin: 0px 0 40px 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@@ -500,8 +487,7 @@ h2 {
|
||||
|
||||
#legals.content-wrapper {
|
||||
max-width: 90%;
|
||||
margin: auto;
|
||||
margin-top: 50px;
|
||||
margin: 50px auto;
|
||||
}
|
||||
|
||||
.legals-content {
|
||||
|
||||
@@ -11,6 +11,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
|
||||
return
|
||||
|
||||
img = Image.open(input_path)
|
||||
icc_profile = img.info.get("icc_profile")
|
||||
if img.mode != "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":
|
||||
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}")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+26
-5
@@ -30,6 +30,8 @@ app = Flask(
|
||||
static_url_path=""
|
||||
)
|
||||
|
||||
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
|
||||
|
||||
# --- Config paths ---
|
||||
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
|
||||
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
|
||||
@@ -72,9 +74,14 @@ def get_local_fonts(theme_name):
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
|
||||
|
||||
@app.context_processor
|
||||
def inject_version():
|
||||
return dict(lumeex_version=lumeex_version)
|
||||
return dict(
|
||||
lumeex_version=lumeex_version,
|
||||
preview_port=PREVIEW_PORT
|
||||
)
|
||||
|
||||
# --- Gallery & Hero API ---
|
||||
@app.route("/gallery-editor")
|
||||
@@ -203,9 +210,21 @@ def get_site_info():
|
||||
@app.route("/api/site-info", methods=["POST"])
|
||||
def update_site_info():
|
||||
"""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:
|
||||
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"})
|
||||
|
||||
# --- Theme management ---
|
||||
@@ -396,7 +415,8 @@ def upload_font():
|
||||
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
|
||||
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||
file.save(fonts_dir / file.filename)
|
||||
return jsonify({"status": "ok", "filename": file.filename})
|
||||
font_basename = Path(file.filename).stem
|
||||
return jsonify({"status": "ok", "filename": font_basename})
|
||||
|
||||
@app.route("/api/font/remove", methods=["POST"])
|
||||
def remove_font():
|
||||
@@ -487,5 +507,6 @@ def download_output_zip():
|
||||
|
||||
# --- Run server ---
|
||||
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)
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
@@ -18,7 +18,16 @@
|
||||
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
|
||||
</div>
|
||||
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
|
||||
<span id="hero-count" class="photo-count"></span>
|
||||
<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>
|
||||
|
||||
<!-- Gallery Upload Section -->
|
||||
@@ -31,8 +40,27 @@
|
||||
</label>
|
||||
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
|
||||
</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>
|
||||
<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 class="section">
|
||||
|
||||
@@ -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
|
||||
if (buildModal && buildModalClose) {
|
||||
buildModalClose.onclick = () => {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
// --- Arrays to store gallery and hero images ---
|
||||
let galleryImages = [];
|
||||
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 ---
|
||||
async function loadData() {
|
||||
try {
|
||||
const galleryRes = await fetch('/api/gallery');
|
||||
galleryImages = await galleryRes.json();
|
||||
galleryImages = await (await fetch('/api/gallery')).json();
|
||||
updateAllTags();
|
||||
renderGallery();
|
||||
|
||||
const heroRes = await fetch('/api/hero');
|
||||
heroImages = await heroRes.json();
|
||||
heroImages = await (await fetch('/api/hero')).json();
|
||||
renderHero();
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
@@ -24,22 +35,43 @@ async function loadData() {
|
||||
function updateAllTags() {
|
||||
allTags = [];
|
||||
galleryImages.forEach(img => {
|
||||
if (img.tags) img.tags.forEach(t => {
|
||||
(img.tags || []).forEach(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 ---
|
||||
function renderGallery() {
|
||||
const container = document.getElementById('gallery');
|
||||
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');
|
||||
div.className = 'photo flex-item flex-column';
|
||||
div.innerHTML = `
|
||||
<div class="flex-item">
|
||||
<img src="/photos/${img.src}">
|
||||
<img class="fade-in-img" src="/photos/${img.src}">
|
||||
</div>
|
||||
<div class="tags-display" data-index="${i}"></div>
|
||||
<div class="flex-item flex-full">
|
||||
@@ -50,22 +82,17 @@ function renderGallery() {
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
|
||||
renderTags(i, img.tags || []);
|
||||
});
|
||||
|
||||
// Show/hide Remove All button
|
||||
const removeAllBtn = document.getElementById('remove-all-gallery');
|
||||
if (removeAllBtn) {
|
||||
removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
updateCountAndButtons('gallery', imagesToShow.length);
|
||||
applyFadeInImages(container);
|
||||
}
|
||||
|
||||
// --- Render tags for a single image ---
|
||||
function renderTags(imgIndex, tags) {
|
||||
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
|
||||
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
|
||||
|
||||
tagsDisplay.innerHTML = '';
|
||||
inputContainer.innerHTML = '';
|
||||
|
||||
@@ -73,7 +100,6 @@ function renderTags(imgIndex, tags) {
|
||||
const span = document.createElement('span');
|
||||
span.className = 'tag';
|
||||
span.textContent = tag;
|
||||
|
||||
const remove = document.createElement('span');
|
||||
remove.className = 'remove-tag';
|
||||
remove.textContent = '×';
|
||||
@@ -82,7 +108,6 @@ function renderTags(imgIndex, tags) {
|
||||
updateTags(imgIndex, tags);
|
||||
renderTags(imgIndex, tags);
|
||||
};
|
||||
|
||||
span.appendChild(remove);
|
||||
tagsDisplay.appendChild(span);
|
||||
});
|
||||
@@ -92,11 +117,10 @@ function renderTags(imgIndex, tags) {
|
||||
input.placeholder = 'Add tag...';
|
||||
inputContainer.appendChild(input);
|
||||
|
||||
// --- Validate button ---
|
||||
const validateBtn = document.createElement('button');
|
||||
validateBtn.textContent = '✔️';
|
||||
validateBtn.className = 'validate-tag-btn';
|
||||
validateBtn.style.display = 'none'; // hidden by default
|
||||
validateBtn.style.display = 'none';
|
||||
validateBtn.style.marginLeft = '4px';
|
||||
inputContainer.appendChild(validateBtn);
|
||||
|
||||
@@ -116,30 +140,20 @@ function renderTags(imgIndex, tags) {
|
||||
|
||||
const updateSuggestions = () => {
|
||||
const value = input.value.toLowerCase();
|
||||
|
||||
const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
|
||||
const tagCount = {};
|
||||
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
|
||||
|
||||
const allTagsSorted = Object.keys(tagCount)
|
||||
.sort((a, b) => tagCount[b] - tagCount[a]);
|
||||
|
||||
const allTagsSorted = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
|
||||
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
|
||||
|
||||
suggestionBox.innerHTML = '';
|
||||
selectedIndex = -1;
|
||||
|
||||
if (suggestions.length) {
|
||||
suggestionBox.style.display = 'block';
|
||||
suggestions.forEach((s, idx) => {
|
||||
const li = document.createElement('li');
|
||||
li.style.fontStyle = 'italic';
|
||||
li.style.textAlign = 'left';
|
||||
|
||||
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
|
||||
const rest = s.substring(input.value.length);
|
||||
li.innerHTML = boldPart + rest;
|
||||
|
||||
li.innerHTML = `<b>${s.substring(0, input.value.length)}</b>${s.substring(input.value.length)}`;
|
||||
li.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
addTag(s);
|
||||
@@ -147,7 +161,6 @@ function renderTags(imgIndex, tags) {
|
||||
input.focus();
|
||||
updateSuggestions();
|
||||
});
|
||||
|
||||
li.onmouseover = () => selectedIndex = idx;
|
||||
suggestionBox.appendChild(li);
|
||||
});
|
||||
@@ -164,7 +177,6 @@ function renderTags(imgIndex, tags) {
|
||||
updateSuggestions();
|
||||
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
const items = suggestionBox.querySelectorAll('li');
|
||||
if (e.key === 'ArrowDown') {
|
||||
@@ -195,27 +207,22 @@ function renderTags(imgIndex, tags) {
|
||||
validateBtn.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', () => {
|
||||
setTimeout(() => {
|
||||
suggestionBox.style.display = 'none';
|
||||
input.value = '';
|
||||
validateBtn.style.display = 'none';
|
||||
}, 150);
|
||||
});
|
||||
|
||||
// --- Validate button action ---
|
||||
validateBtn.onclick = () => {
|
||||
validateBtn.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
if (input.value.trim()) {
|
||||
addTag(input.value.trim());
|
||||
input.value = '';
|
||||
updateSuggestions();
|
||||
validateBtn.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
input.focus();
|
||||
});
|
||||
updateSuggestions();
|
||||
if (!input.value.trim()) suggestionBox.style.display = 'none';
|
||||
}
|
||||
|
||||
// --- Update tags in galleryImages array ---
|
||||
@@ -233,7 +240,7 @@ function renderHero() {
|
||||
div.className = 'photo flex-item flex-column';
|
||||
div.innerHTML = `
|
||||
<div class="flex-item">
|
||||
<img src="/photos/${img.src}">
|
||||
<img class="fade-in-img" src="/photos/${img.src}">
|
||||
</div>
|
||||
<div class="flex-item flex-full">
|
||||
<div class="flex-item flex-end">
|
||||
@@ -243,12 +250,8 @@ function renderHero() {
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
// Show/hide Remove All button
|
||||
const removeAllBtn = document.getElementById('remove-all-hero');
|
||||
if (removeAllBtn) {
|
||||
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
|
||||
}
|
||||
updateCountAndButtons('hero', heroImages.length);
|
||||
applyFadeInImages(container);
|
||||
}
|
||||
|
||||
// --- Save gallery to server ---
|
||||
@@ -294,33 +297,27 @@ async function refreshHero() {
|
||||
function showToast(message, type = "success", duration = 3000) {
|
||||
const container = document.getElementById("toast-container");
|
||||
if (!container) return;
|
||||
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast ${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
|
||||
requestAnimationFrame(() => toast.classList.add("show"));
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove("show");
|
||||
toast.addEventListener("transitionend", () => toast.remove());
|
||||
}, duration);
|
||||
}
|
||||
|
||||
let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null }
|
||||
let pendingDelete = null;
|
||||
|
||||
// --- Show delete confirmation modal ---
|
||||
function showDeleteModal(type, index = null) {
|
||||
pendingDelete = { type, index };
|
||||
const modalText = document.getElementById('delete-modal-text');
|
||||
if (type === 'gallery-all') {
|
||||
modalText.textContent = "Are you sure you want to delete ALL gallery images?";
|
||||
} else if (type === 'hero-all') {
|
||||
modalText.textContent = "Are you sure you want to delete ALL hero images?";
|
||||
} else {
|
||||
modalText.textContent = "Are you sure you want to delete this image?";
|
||||
}
|
||||
modalText.textContent =
|
||||
type === 'gallery-all' ? "Are you sure you want to delete ALL gallery images?"
|
||||
: type === 'hero-all' ? "Are you sure you want to delete ALL hero images?"
|
||||
: "Are you sure you want to delete this image?";
|
||||
document.getElementById('delete-modal').style.display = 'flex';
|
||||
}
|
||||
|
||||
@@ -333,15 +330,10 @@ function hideDeleteModal() {
|
||||
// --- Confirm deletion ---
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete) return;
|
||||
if (pendingDelete.type === 'gallery') {
|
||||
await actuallyDeleteGalleryImage(pendingDelete.index);
|
||||
} else if (pendingDelete.type === 'hero') {
|
||||
await actuallyDeleteHeroImage(pendingDelete.index);
|
||||
} else if (pendingDelete.type === 'gallery-all') {
|
||||
await actuallyDeleteAllGalleryImages();
|
||||
} else if (pendingDelete.type === 'hero-all') {
|
||||
await actuallyDeleteAllHeroImages();
|
||||
}
|
||||
if (pendingDelete.type === 'gallery') await actuallyDeleteGalleryImage(pendingDelete.index);
|
||||
else if (pendingDelete.type === 'hero') await actuallyDeleteHeroImage(pendingDelete.index);
|
||||
else if (pendingDelete.type === 'gallery-all') await actuallyDeleteAllGalleryImages();
|
||||
else if (pendingDelete.type === 'hero-all') await actuallyDeleteAllHeroImages();
|
||||
hideDeleteModal();
|
||||
}
|
||||
|
||||
@@ -423,15 +415,35 @@ async function actuallyDeleteAllHeroImages() {
|
||||
|
||||
// --- Modal event listeners and bulk delete buttons ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('delete-modal-close').onclick = hideDeleteModal;
|
||||
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal;
|
||||
document.getElementById('delete-modal-confirm').onclick = confirmDelete;
|
||||
['delete-modal-close', 'delete-modal-cancel'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
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
|
||||
const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
|
||||
const removeAllHeroBtn = document.getElementById('remove-all-hero');
|
||||
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
|
||||
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
|
||||
[
|
||||
['remove-all-gallery', 'gallery-all'],
|
||||
['remove-all-gallery-bottom', 'gallery-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 ---
|
||||
|
||||
+301
-223
@@ -12,7 +12,6 @@ function showToast(message, type = "success", duration = 3000) {
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// --- Loader helpers ---
|
||||
function showLoader(text = "Uploading...") {
|
||||
const loader = document.getElementById("global-loader");
|
||||
if (loader) {
|
||||
@@ -26,119 +25,99 @@ function hideLoader() {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// Form and menu logic
|
||||
const form = document.getElementById("site-info-form");
|
||||
// --- Section Forms ---
|
||||
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 addMenuBtn = document.getElementById("add-menu-item");
|
||||
|
||||
let menuItems = [];
|
||||
|
||||
// Render menu items
|
||||
function renderMenuItems() {
|
||||
menuList.innerHTML = "";
|
||||
menuItems.forEach((item, idx) => {
|
||||
const div = document.createElement("div");
|
||||
div.style.display = "flex";
|
||||
div.style.gap = "8px";
|
||||
div.style.marginBottom = "6px";
|
||||
div.innerHTML = `
|
||||
menuList.innerHTML += `
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
|
||||
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
|
||||
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
|
||||
</div>
|
||||
`;
|
||||
menuList.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// Update menu items from inputs
|
||||
function updateMenuItemsFromInputs() {
|
||||
const inputs = menuList.querySelectorAll("input");
|
||||
const items = [];
|
||||
menuItems = [];
|
||||
for (let i = 0; i < inputs.length; i += 2) {
|
||||
const label = inputs[i].value.trim();
|
||||
const href = inputs[i + 1].value.trim();
|
||||
if (label || href) items.push({ label, href });
|
||||
if (label || href) menuItems.push({ label, href });
|
||||
}
|
||||
menuItems = items;
|
||||
}
|
||||
|
||||
// Intellectual property paragraphs logic
|
||||
// --- Intellectual property paragraphs logic ---
|
||||
const ipList = document.getElementById("ip-list");
|
||||
const addIpBtn = document.getElementById("add-ip-paragraph");
|
||||
let ipParagraphs = [];
|
||||
|
||||
// Render IP paragraphs
|
||||
function renderIpParagraphs() {
|
||||
ipList.innerHTML = "";
|
||||
ipParagraphs.forEach((item, idx) => {
|
||||
const div = document.createElement("div");
|
||||
div.style.display = "flex";
|
||||
div.style.gap = "8px";
|
||||
div.style.marginBottom = "6px";
|
||||
div.innerHTML = `
|
||||
ipList.innerHTML += `
|
||||
<div style="display:flex;gap:8px;margin-bottom:6px;">
|
||||
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
|
||||
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
|
||||
</div>
|
||||
`;
|
||||
ipList.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// Update IP paragraphs from textareas
|
||||
function updateIpParagraphsFromInputs() {
|
||||
const textareas = ipList.querySelectorAll("textarea");
|
||||
ipParagraphs = Array.from(textareas).map(textarea => ({
|
||||
paragraph: textarea.value.trim()
|
||||
})).filter(item => item.paragraph !== "");
|
||||
ipParagraphs = Array.from(ipList.querySelectorAll("textarea"))
|
||||
.map(textarea => ({ paragraph: textarea.value.trim() }))
|
||||
.filter(item => item.paragraph !== "");
|
||||
}
|
||||
|
||||
// Build options
|
||||
// --- Build options & Theme select ---
|
||||
const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
|
||||
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
|
||||
|
||||
// Theme select
|
||||
const themeSelect = document.getElementById("theme-select");
|
||||
|
||||
// Thumbnail upload and modal logic
|
||||
const thumbnailInput = form?.elements["social.thumbnail"];
|
||||
// --- Thumbnail upload and modal logic ---
|
||||
const thumbnailInput = document.getElementById("social-thumbnail");
|
||||
const thumbnailUpload = document.getElementById("thumbnail-upload");
|
||||
const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
|
||||
const thumbnailPreview = document.getElementById("thumbnail-preview");
|
||||
const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
|
||||
|
||||
// Modal elements for delete confirmation
|
||||
const deleteModal = document.getElementById("delete-modal");
|
||||
const deleteModalClose = document.getElementById("delete-modal-close");
|
||||
const deleteModalConfirm = document.getElementById("delete-modal-confirm");
|
||||
const deleteModalCancel = document.getElementById("delete-modal-cancel");
|
||||
// --- Modal helpers ---
|
||||
function setupModal(modal, closeBtn, confirmBtn, cancelBtn, onConfirm) {
|
||||
if (!modal) return;
|
||||
if (closeBtn) closeBtn.onclick = () => modal.style.display = "none";
|
||||
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
|
||||
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
|
||||
// --- Thumbnail preview logic ---
|
||||
function updateThumbnailPreview(src) {
|
||||
if (thumbnailPreview) {
|
||||
thumbnailPreview.src = src || "";
|
||||
thumbnailPreview.style.display = src ? "block" : "none";
|
||||
}
|
||||
if (removeThumbnailBtn) {
|
||||
removeThumbnailBtn.style.display = src ? "inline-block" : "none";
|
||||
}
|
||||
if (chooseThumbnailBtn) {
|
||||
chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
|
||||
}
|
||||
if (removeThumbnailBtn) removeThumbnailBtn.style.display = src ? "inline-block" : "none";
|
||||
if (chooseThumbnailBtn) chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
|
||||
}
|
||||
|
||||
// Choose thumbnail button triggers file input
|
||||
if (chooseThumbnailBtn && thumbnailUpload) {
|
||||
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
|
||||
}
|
||||
|
||||
// Handle thumbnail upload and refresh preview (with cache busting)
|
||||
if (thumbnailUpload) {
|
||||
thumbnailUpload.addEventListener("change", async (e) => {
|
||||
const file = e.target.files[0];
|
||||
@@ -156,27 +135,20 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
} else {
|
||||
showToast("❌ Error uploading thumbnail", "error");
|
||||
}
|
||||
updateSectionStatus("social");
|
||||
});
|
||||
}
|
||||
|
||||
// Remove thumbnail button triggers modal
|
||||
if (removeThumbnailBtn) {
|
||||
removeThumbnailBtn.addEventListener("click", () => {
|
||||
deleteModal.style.display = "flex";
|
||||
document.getElementById("delete-modal").style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
// Modal logic for thumbnail deletion
|
||||
if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) {
|
||||
deleteModalClose.onclick = deleteModalCancel.onclick = () => {
|
||||
deleteModal.style.display = "none";
|
||||
};
|
||||
window.onclick = function(event) {
|
||||
if (event.target === deleteModal) {
|
||||
deleteModal.style.display = "none";
|
||||
}
|
||||
};
|
||||
deleteModalConfirm.onclick = async () => {
|
||||
setupModal(
|
||||
document.getElementById("delete-modal"),
|
||||
document.getElementById("delete-modal-close"),
|
||||
document.getElementById("delete-modal-confirm"),
|
||||
document.getElementById("delete-modal-cancel"),
|
||||
async () => {
|
||||
const res = await fetch("/api/thumbnail/remove", { method: "POST" });
|
||||
const result = await res.json();
|
||||
if (result.status === "ok") {
|
||||
@@ -186,48 +158,38 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
} else {
|
||||
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 chooseThemeBtn = document.getElementById("choose-theme-btn");
|
||||
if (chooseThemeBtn && themeUpload) {
|
||||
chooseThemeBtn.addEventListener("click", () => themeUpload.click());
|
||||
themeUpload.addEventListener("change", async (e) => {
|
||||
const files = Array.from(e.target.files);
|
||||
if (files.length === 0) return;
|
||||
if (!files.length) return;
|
||||
showLoader("Uploading theme...");
|
||||
const formData = new FormData();
|
||||
files.forEach(file => {
|
||||
formData.append("files", file, file.webkitRelativePath || file.name);
|
||||
});
|
||||
files.forEach(file => formData.append("files", file, file.webkitRelativePath || file.name));
|
||||
const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
|
||||
const result = await res.json();
|
||||
hideLoader();
|
||||
if (result.status === "ok") {
|
||||
showToast("✅ Theme uploaded!", "success");
|
||||
// Refresh theme select after upload
|
||||
fetch("/api/themes")
|
||||
.then(res => res.json())
|
||||
.then(themes => {
|
||||
themeSelect.innerHTML = "";
|
||||
themes.forEach(theme => {
|
||||
const option = document.createElement("option");
|
||||
option.value = theme;
|
||||
option.textContent = theme;
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
refreshThemes();
|
||||
} else {
|
||||
showToast("❌ Error uploading theme", "error");
|
||||
}
|
||||
updateSectionStatus("build");
|
||||
});
|
||||
}
|
||||
|
||||
// Remove theme button triggers modal
|
||||
// --- Remove theme logic ---
|
||||
const removeThemeBtn = document.getElementById("remove-theme-btn");
|
||||
let themeToDelete = null;
|
||||
if (removeThemeBtn && themeSelect) {
|
||||
removeThemeBtn.addEventListener("click", () => {
|
||||
const theme = themeSelect.value;
|
||||
@@ -237,24 +199,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
return;
|
||||
}
|
||||
themeToDelete = theme;
|
||||
deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`;
|
||||
deleteThemeModal.style.display = "flex";
|
||||
document.getElementById("delete-theme-modal-text").textContent = `Are you sure you want to remove theme "${theme}"?`;
|
||||
document.getElementById("delete-theme-modal").style.display = "flex";
|
||||
});
|
||||
}
|
||||
|
||||
// Modal logic for theme deletion
|
||||
if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) {
|
||||
deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => {
|
||||
deleteThemeModal.style.display = "none";
|
||||
themeToDelete = null;
|
||||
};
|
||||
window.onclick = function(event) {
|
||||
if (event.target === deleteThemeModal) {
|
||||
deleteThemeModal.style.display = "none";
|
||||
themeToDelete = null;
|
||||
}
|
||||
};
|
||||
deleteThemeModalConfirm.onclick = async () => {
|
||||
setupModal(
|
||||
document.getElementById("delete-theme-modal"),
|
||||
document.getElementById("delete-theme-modal-close"),
|
||||
document.getElementById("delete-theme-modal-confirm"),
|
||||
document.getElementById("delete-theme-modal-cancel"),
|
||||
async () => {
|
||||
if (!themeToDelete) return;
|
||||
showLoader("Removing theme...");
|
||||
const res = await fetch("/api/theme/remove", {
|
||||
@@ -266,28 +220,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
hideLoader();
|
||||
if (result.status === "ok") {
|
||||
showToast("✅ Theme removed!", "success");
|
||||
// Refresh theme select
|
||||
fetch("/api/themes")
|
||||
.then(res => res.json())
|
||||
.then(themes => {
|
||||
themeSelect.innerHTML = "";
|
||||
themes.forEach(theme => {
|
||||
const option = document.createElement("option");
|
||||
option.value = theme;
|
||||
option.textContent = theme;
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
});
|
||||
refreshThemes();
|
||||
} else {
|
||||
showToast(result.error || "❌ Error removing theme", "error");
|
||||
}
|
||||
deleteThemeModal.style.display = "none";
|
||||
document.getElementById("delete-theme-modal").style.display = "none";
|
||||
themeToDelete = null;
|
||||
};
|
||||
updateSectionStatus("build");
|
||||
}
|
||||
);
|
||||
|
||||
// Fetch theme list and populate select
|
||||
if (themeSelect) {
|
||||
// --- Theme select refresh ---
|
||||
function refreshThemes() {
|
||||
fetch("/api/themes")
|
||||
.then(res => res.json())
|
||||
.then(themes => {
|
||||
@@ -298,148 +242,276 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
option.textContent = theme;
|
||||
themeSelect.appendChild(option);
|
||||
});
|
||||
// Set selected value after loading config
|
||||
fetch("/api/site-info")
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
themeSelect.value = data.build?.theme || "";
|
||||
});
|
||||
loadConfigAndUpdateBuildStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// Load config from server and populate form
|
||||
if (form) {
|
||||
// --- Config loading ---
|
||||
let loadedConfig = {};
|
||||
function loadConfigAndUpdateBuildStatus() {
|
||||
fetch("/api/site-info")
|
||||
.then(res => res.json())
|
||||
.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)
|
||||
? data.legals.intellectual_property
|
||||
: [];
|
||||
renderIpParagraphs();
|
||||
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
|
||||
renderMenuItems();
|
||||
form.elements["info.title"].value = data.info?.title || "";
|
||||
form.elements["info.subtitle"].value = data.info?.subtitle || "";
|
||||
form.elements["info.description"].value = data.info?.description || "";
|
||||
form.elements["info.canonical"].value = data.info?.canonical || "";
|
||||
form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
|
||||
form.elements["info.author"].value = data.info?.author || "";
|
||||
form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
|
||||
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
|
||||
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
|
||||
form.elements["footer.copyright"].value = data.footer?.copyright || "";
|
||||
form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
|
||||
if (themeSelect) {
|
||||
themeSelect.value = data.build?.theme || "";
|
||||
}
|
||||
form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
|
||||
form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
|
||||
form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
|
||||
// Build checkboxes
|
||||
if (convertImagesCheckbox) {
|
||||
convertImagesCheckbox.checked = !!data.build?.convert_images;
|
||||
}
|
||||
if (resizeImagesCheckbox) {
|
||||
resizeImagesCheckbox.checked = !!data.build?.resize_images;
|
||||
if (forms.legals) {
|
||||
forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
|
||||
forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
|
||||
forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
|
||||
}
|
||||
// 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
|
||||
if (addMenuBtn) {
|
||||
addMenuBtn.addEventListener("click", () => {
|
||||
// --- Add/remove menu items ---
|
||||
if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
|
||||
menuItems.push({ label: "", href: "" });
|
||||
renderMenuItems();
|
||||
updateSectionStatus("menu");
|
||||
});
|
||||
}
|
||||
|
||||
// Remove menu item
|
||||
menuList.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("remove-menu-item")) {
|
||||
const idx = parseInt(e.target.getAttribute("data-idx"));
|
||||
menuItems.splice(idx, 1);
|
||||
renderMenuItems();
|
||||
updateSectionStatus("menu");
|
||||
}
|
||||
});
|
||||
|
||||
// Update menuItems on input change
|
||||
menuList.addEventListener("input", () => {
|
||||
updateMenuItemsFromInputs();
|
||||
updateSectionStatus("menu");
|
||||
});
|
||||
|
||||
// Add paragraph
|
||||
if (addIpBtn) {
|
||||
addIpBtn.addEventListener("click", () => {
|
||||
// --- Add/remove IP paragraphs ---
|
||||
if (addIpBtn) addIpBtn.addEventListener("click", () => {
|
||||
ipParagraphs.push({ paragraph: "" });
|
||||
renderIpParagraphs();
|
||||
updateSectionStatus("legals");
|
||||
});
|
||||
}
|
||||
|
||||
// Remove paragraph
|
||||
ipList.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("remove-ip-paragraph")) {
|
||||
const idx = parseInt(e.target.getAttribute("data-idx"));
|
||||
ipParagraphs.splice(idx, 1);
|
||||
renderIpParagraphs();
|
||||
updateSectionStatus("legals");
|
||||
}
|
||||
});
|
||||
|
||||
// Update ipParagraphs on input change
|
||||
ipList.addEventListener("input", () => {
|
||||
updateIpParagraphsFromInputs();
|
||||
updateSectionStatus("legals");
|
||||
});
|
||||
|
||||
// Save config to server
|
||||
if (form) {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
// --- Section value helpers ---
|
||||
function getSectionValues(section) {
|
||||
switch (section) {
|
||||
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();
|
||||
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();
|
||||
|
||||
// Check if thumbnail is set before saving (uploaded or present in input)
|
||||
if (!thumbnailInput || !thumbnailInput.value) {
|
||||
showLoader("Saving...");
|
||||
showToast("❌ Thumbnail is required.", "error");
|
||||
hideLoader();
|
||||
return;
|
||||
}
|
||||
|
||||
const build = {
|
||||
return {
|
||||
hoster_name: forms.legals.elements["legals.hoster_name"].value,
|
||||
hoster_address: forms.legals.elements["legals.hoster_address"].value,
|
||||
hoster_contact: forms.legals.elements["legals.hoster_contact"].value,
|
||||
intellectual_property: ipParagraphs
|
||||
};
|
||||
case "build":
|
||||
return {
|
||||
theme: themeSelect ? themeSelect.value : "",
|
||||
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked),
|
||||
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked)
|
||||
};
|
||||
|
||||
const payload = {
|
||||
info: {
|
||||
title: form.elements["info.title"].value,
|
||||
subtitle: form.elements["info.subtitle"].value,
|
||||
description: form.elements["info.description"].value,
|
||||
canonical: form.elements["info.canonical"].value,
|
||||
keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
|
||||
author: form.elements["info.author"].value
|
||||
},
|
||||
social: {
|
||||
instagram_url: form.elements["social.instagram_url"].value,
|
||||
thumbnail: thumbnailInput ? thumbnailInput.value : ""
|
||||
},
|
||||
menu: {
|
||||
items: menuItems
|
||||
},
|
||||
footer: {
|
||||
copyright: form.elements["footer.copyright"].value,
|
||||
legal_label: form.elements["footer.legal_label"].value
|
||||
},
|
||||
build,
|
||||
legals: {
|
||||
hoster_name: form.elements["legals.hoster_name"].value,
|
||||
hoster_address: form.elements["legals.hoster_address"].value,
|
||||
hoster_contact: form.elements["legals.hoster_contact"].value,
|
||||
intellectual_property: ipParagraphs
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
};
|
||||
// --- 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", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -447,10 +519,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
const result = await res.json();
|
||||
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 {
|
||||
showToast("❌ Error saving site info", "error");
|
||||
showToast("❌ Error saving section", "error");
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
+207
-66
@@ -70,9 +70,11 @@ function setupColorPicker(colorId, btnId, textId, initial) {
|
||||
function setFontDropdown(selectId, value, options) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
select.innerHTML = options.map(opt =>
|
||||
`<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>`
|
||||
).join("");
|
||||
select.innerHTML = options.map(opt => {
|
||||
// Remove extension if present
|
||||
const base = opt.replace(/\.(woff2?|ttf|otf)$/, "");
|
||||
return `<option value="${base}"${base === value ? " selected" : ""}>${base}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
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 () => {
|
||||
const themeInfo = await fetchThemeInfo();
|
||||
const themeNameSpan = document.getElementById("current-theme");
|
||||
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
|
||||
|
||||
const themeYaml = themeInfo.theme_yaml;
|
||||
const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : [];
|
||||
let loadedConfig = themeInfo.theme_yaml;
|
||||
let googleFonts = loadedConfig.google_fonts ? JSON.parse(JSON.stringify(loadedConfig.google_fonts)) : [];
|
||||
let localFonts = await fetchLocalFonts(themeInfo.theme_name);
|
||||
|
||||
// Colors
|
||||
if (themeYaml.colors) {
|
||||
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1");
|
||||
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384");
|
||||
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0");
|
||||
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700");
|
||||
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161");
|
||||
setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff");
|
||||
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff");
|
||||
if (loadedConfig.colors) {
|
||||
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", loadedConfig.colors.primary_dark || "#005384");
|
||||
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", loadedConfig.colors.secondary || "#00b0f0");
|
||||
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", loadedConfig.colors.text_dark || "#616161");
|
||||
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", loadedConfig.colors.browser_color || "#fff");
|
||||
}
|
||||
|
||||
// Fonts
|
||||
function refreshFontDropdowns() {
|
||||
setFontDropdown("font-primary", document.getElementById("font-primary").value, [
|
||||
setFontDropdown("font-primary", loadedConfig.fonts?.primary?.name || "Lato", [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [
|
||||
setFontDropdown("font-secondary", loadedConfig.fonts?.secondary?.name || "Montserrat", [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
setFallbackDropdown("font-primary-fallback", loadedConfig.fonts?.primary?.fallback || "sans-serif");
|
||||
setFallbackDropdown("font-secondary-fallback", loadedConfig.fonts?.secondary?.fallback || "serif");
|
||||
}
|
||||
if (themeYaml.fonts) {
|
||||
setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
|
||||
setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
|
||||
...googleFonts.map(f => f.family),
|
||||
...localFonts
|
||||
]);
|
||||
setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
|
||||
}
|
||||
refreshFontDropdowns();
|
||||
|
||||
// Font upload logic
|
||||
const fontUploadInput = document.getElementById("font-upload");
|
||||
const chooseFontBtn = document.getElementById("choose-font-btn");
|
||||
const fontUploadStatus = document.getElementById("font-upload-status");
|
||||
const localFontsList = document.getElementById("local-fonts-list");
|
||||
|
||||
// Modal logic for font deletion
|
||||
@@ -341,9 +462,9 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
};
|
||||
}
|
||||
|
||||
if (themeYaml.favicon && themeYaml.favicon.path) {
|
||||
faviconInput.value = themeYaml.favicon.path;
|
||||
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`);
|
||||
if (loadedConfig.favicon && loadedConfig.favicon.path) {
|
||||
faviconInput.value = loadedConfig.favicon.path;
|
||||
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${loadedConfig.favicon.path}?t=${Date.now()}`);
|
||||
} else {
|
||||
updateFaviconPreview("");
|
||||
}
|
||||
@@ -367,6 +488,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
googleFonts.push(...updatedGoogleFonts);
|
||||
renderGoogleFonts(googleFonts);
|
||||
refreshFontDropdowns();
|
||||
updateSectionStatus("google-fonts", loadedConfig);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -396,6 +518,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
googleFonts.push(...updatedGoogleFonts);
|
||||
renderGoogleFonts(googleFonts);
|
||||
refreshFontDropdowns();
|
||||
updateSectionStatus("google-fonts", loadedConfig);
|
||||
}
|
||||
}, true);
|
||||
|
||||
@@ -414,54 +537,72 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
googleFonts.push(...updatedGoogleFonts);
|
||||
renderGoogleFonts(googleFonts);
|
||||
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();
|
||||
showLoader("Saving theme...");
|
||||
const data = {};
|
||||
data.colors = {
|
||||
primary: document.getElementById("color-primary-text").value,
|
||||
primary_dark: document.getElementById("color-primary-dark-text").value,
|
||||
secondary: document.getElementById("color-secondary-text").value,
|
||||
accent: document.getElementById("color-accent-text").value,
|
||||
text_dark: document.getElementById("color-text-dark-text").value,
|
||||
background: document.getElementById("color-background-text").value,
|
||||
browser_color: document.getElementById("color-browser-color-text").value
|
||||
};
|
||||
data.fonts = {
|
||||
primary: {
|
||||
name: document.getElementById("font-primary").value,
|
||||
fallback: document.getElementById("font-primary-fallback").value
|
||||
},
|
||||
secondary: {
|
||||
name: document.getElementById("font-secondary").value,
|
||||
fallback: document.getElementById("font-secondary-fallback").value
|
||||
if (!form.reportValidity() || !isSectionComplete(section)) {
|
||||
showToast("❌ Please fill all required fields before saving.", "error");
|
||||
updateSectionStatus(section, loadedConfig);
|
||||
return;
|
||||
}
|
||||
};
|
||||
data.favicon = {
|
||||
path: faviconInput.value
|
||||
};
|
||||
data.google_fonts = [];
|
||||
document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => {
|
||||
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value;
|
||||
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
|
||||
.split(",").map(w => w.trim()).filter(w => w);
|
||||
if (family) data.google_fonts.push({ family, weights });
|
||||
});
|
||||
|
||||
// Merge with loadedConfig to avoid overwriting other sections
|
||||
let payload = { ...loadedConfig };
|
||||
switch (section) {
|
||||
case "colors":
|
||||
payload.colors = getSectionValues("colors");
|
||||
break;
|
||||
case "google-fonts":
|
||||
payload.google_fonts = getSectionValues("google-fonts");
|
||||
break;
|
||||
case "fonts":
|
||||
payload.fonts = getSectionValues("fonts");
|
||||
break;
|
||||
case "favicon":
|
||||
payload.favicon = getSectionValues("favicon");
|
||||
break;
|
||||
}
|
||||
showLoader("Saving...");
|
||||
const res = await fetch("/api/theme-info", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data })
|
||||
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: payload })
|
||||
});
|
||||
hideLoader();
|
||||
if (res.ok) {
|
||||
showToast("✅ Theme saved!", "success");
|
||||
showToast("✅ Section saved!", "success");
|
||||
const updatedThemeInfo = await fetchThemeInfo();
|
||||
loadedConfig = updatedThemeInfo.theme_yaml;
|
||||
updateSectionStatus(section, loadedConfig);
|
||||
} 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
@@ -11,54 +11,37 @@ function hideLoader() {
|
||||
if (loader) loader.classList.remove("active");
|
||||
}
|
||||
|
||||
// --- Upload gallery images ---
|
||||
const galleryInput = document.getElementById('upload-gallery');
|
||||
if (galleryInput) {
|
||||
galleryInput.addEventListener('change', async (e) => {
|
||||
// --- Generic upload handler ---
|
||||
function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) {
|
||||
const input = document.getElementById(inputId);
|
||||
if (!input) return;
|
||||
input.addEventListener('change', async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files.length) return;
|
||||
showLoader("Uploading photos...");
|
||||
showLoader(loaderText);
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append('files', file);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
|
||||
const res = await fetch(apiUrl, { method: 'POST', body: formData });
|
||||
const data = await res.json();
|
||||
hideLoader();
|
||||
if (res.ok) {
|
||||
showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
|
||||
if (typeof refreshGallery === "function") refreshGallery();
|
||||
showToast(`✅ ${data.uploaded.length} ${successMsg}`, "success");
|
||||
if (typeof refreshFn === "function") refreshFn();
|
||||
} else showToast('Error: ' + data.error, "error");
|
||||
} catch(err) {
|
||||
hideLoader();
|
||||
console.error(err);
|
||||
showToast('Server error!', "error");
|
||||
} finally { e.target.value = ''; }
|
||||
} finally {
|
||||
e.target.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Upload hero images ---
|
||||
const heroInput = document.getElementById('upload-hero');
|
||||
if (heroInput) {
|
||||
heroInput.addEventListener('change', async (e) => {
|
||||
const files = e.target.files;
|
||||
if (!files.length) return;
|
||||
showLoader("Uploading hero photos...");
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append('files', file);
|
||||
|
||||
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 = ''; }
|
||||
});
|
||||
}
|
||||
// --- Setup all upload inputs ---
|
||||
setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
|
||||
setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
|
||||
setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
|
||||
setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
|
||||
@@ -5,10 +5,12 @@
|
||||
{% block content %}
|
||||
|
||||
<h1>Edit Site Info</h1>
|
||||
<form id="site-info-form">
|
||||
|
||||
<!-- Info Section -->
|
||||
<fieldset>
|
||||
<form id="info-form" autocomplete="off">
|
||||
<fieldset id="info-section">
|
||||
<h2>Info</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Set the basic information for your site and SEO</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
@@ -36,10 +38,15 @@
|
||||
<input type="text" name="info.author" placeholder="Your Name" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="info">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<!-- Social Section -->
|
||||
<fieldset>
|
||||
<form id="social-form" autocomplete="off">
|
||||
<fieldset id="social-section">
|
||||
<h2>Social</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Set your social media links and thumbnail for link sharing</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
@@ -49,16 +56,21 @@
|
||||
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
|
||||
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
|
||||
<div class="thumbnail-form">
|
||||
<input type="hidden" name="social.thumbnail" id="social-thumbnail">
|
||||
<input type="hidden" name="social.thumbnail" id="social-thumbnail" required>
|
||||
<img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
|
||||
<button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="social">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<!-- Menu Section -->
|
||||
<fieldset>
|
||||
<form id="menu-form" autocomplete="off">
|
||||
<fieldset id="menu-section">
|
||||
<h2>Menu</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Manage your site menu items. You can use tag combination to propose custom filters</p>
|
||||
<div class="fields">
|
||||
<div class="input-field" style="flex: 1 1 100%;">
|
||||
@@ -66,10 +78,15 @@
|
||||
<button type="button" id="add-menu-item">+ Add menu item</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="menu">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<!-- Footer Section -->
|
||||
<fieldset>
|
||||
<form id="footer-form" autocomplete="off">
|
||||
<fieldset id="footer-section">
|
||||
<h2>Footer</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Set your copyright informations and legal link name</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
@@ -78,13 +95,18 @@
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Legal Label</label>
|
||||
<input type="text" name="footer.legal_label" re>
|
||||
<input type="text" name="footer.legal_label" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="footer">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<!-- Legals Section -->
|
||||
<fieldset>
|
||||
<form id="legals-form" autocomplete="off">
|
||||
<fieldset id="legals-section">
|
||||
<h2>Legals</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Set your legal informations</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
@@ -105,15 +127,20 @@
|
||||
<button type="button" id="add-ip-paragraph">+ Add paragraph</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="legals">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<!-- Build Section -->
|
||||
<fieldset>
|
||||
<form id="build-form" autocomplete="off">
|
||||
<fieldset id="build-section">
|
||||
<h2>Build</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Select a theme from the dropdown menu or add your custom theme folder</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
<label>Theme</label>
|
||||
<select name="build.theme" id="theme-select" required></select>
|
||||
<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>
|
||||
<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>
|
||||
@@ -129,9 +156,11 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="build">Save</button>
|
||||
</fieldset>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
|
||||
<!-- Stepper -->
|
||||
<div class="section">
|
||||
<h2>Steps</h2>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete thumbnail confirmation modal-->
|
||||
<div class="content-inner">
|
||||
<div id="delete-modal" class="modal" style="display:none;">
|
||||
|
||||
+99
-37
@@ -7,7 +7,7 @@ body {
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
margin:0px;
|
||||
width: 100vw;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -236,11 +236,32 @@ h2 {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 30px 0 0 0;
|
||||
}
|
||||
|
||||
#hero-count-bottom, #gallery-count-bottom {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.bottom-action-row {
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
@@ -316,6 +337,20 @@ h2 {
|
||||
.toast.success { background-color: #28a7468c; }
|
||||
.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 --- */
|
||||
.tag-input {
|
||||
display: flex;
|
||||
@@ -366,15 +401,18 @@ h2 {
|
||||
z-index: 999;
|
||||
display: none;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
padding: 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tag-input ul.suggestions li {
|
||||
padding: 6px 8px;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tag-input ul.suggestions li:hover {
|
||||
background-color: #007782;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.tags-display {
|
||||
@@ -406,7 +444,9 @@ h2 {
|
||||
background-color: #007782;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.suggestions li {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -416,6 +456,14 @@ h2 {
|
||||
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-direction: column;
|
||||
}
|
||||
@@ -521,18 +569,23 @@ h2 {
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* --- Remove All Buttons --- */
|
||||
#remove-all-hero, #remove-all-gallery {
|
||||
#remove-all-hero, #remove-all-gallery, .bottom-remove-btn {
|
||||
background: #2d2d2d;
|
||||
color: white;
|
||||
display: none;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bottom-remove-btn {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
#remove-all-gallery:hover,
|
||||
#remove-all-hero:hover {
|
||||
#remove-all-hero:hover, .bottom-remove-btn:hover {
|
||||
background: rgb(121, 26, 19);
|
||||
}
|
||||
|
||||
@@ -541,6 +594,7 @@ h2 {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -588,9 +642,9 @@ label {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#site-info-form input, #theme-editor-form input,
|
||||
#site-info-form textarea, #theme-editor-form textarea,
|
||||
#site-info-form select, #theme-editor-form select {
|
||||
form input,
|
||||
form textarea,
|
||||
form select {
|
||||
background: #1f2223;
|
||||
color: #fff;
|
||||
border: 1px solid #585858;
|
||||
@@ -612,27 +666,24 @@ label {
|
||||
gap: 0 18px;
|
||||
}
|
||||
|
||||
.theme-info {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
|
||||
#site-info-form input::placeholder,
|
||||
#theme-editor-form input::placeholder,
|
||||
#site-info-form textarea::placeholder,
|
||||
#theme-editor-form textarea::placeholder {
|
||||
form input::placeholder,
|
||||
form textarea::placeholder {
|
||||
color: #585858;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#site-info-form input:focus,
|
||||
#theme-editor-form input:focus,
|
||||
#site-info-form textarea:focus,
|
||||
#theme-editor-form textarea:focus,
|
||||
#site-info-form select:focus,
|
||||
#theme-editor-form select:focus {
|
||||
form input:focus,
|
||||
form textarea:focus,
|
||||
form select:focus {
|
||||
border-color: #585858;
|
||||
background: #161616;
|
||||
}
|
||||
#site-info-form textarea,
|
||||
#theme-editor-form textarea {
|
||||
|
||||
form textarea {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
@@ -651,7 +702,7 @@ img#thumbnail-preview {
|
||||
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);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
@@ -659,17 +710,18 @@ img#thumbnail-preview {
|
||||
border-radius: 30px;
|
||||
padding: 12px 32px;
|
||||
font-size: 1.1em;
|
||||
margin: 0 0 45px 0;
|
||||
margin: 16px 0 0 0;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(38,196,255,0.15);
|
||||
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);
|
||||
}
|
||||
|
||||
#site-info-form button[type="button"], #theme-editor-form button[type="button"] {
|
||||
form button[type="button"] {
|
||||
background: #00000000;
|
||||
color: #fff;
|
||||
border: none;
|
||||
@@ -682,34 +734,36 @@ img#thumbnail-preview {
|
||||
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;
|
||||
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-bottom: 4px;
|
||||
border-radius: 30px;
|
||||
background: #2d2d2d;
|
||||
}
|
||||
|
||||
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover {
|
||||
form button.remove-menu-item:hover,
|
||||
form button.remove-ip-paragraph:hover {
|
||||
background: rgb(121, 26, 19);
|
||||
}
|
||||
|
||||
#site-info-form button.remove-btn, #theme-editor-form button.remove-btn {
|
||||
form button.remove-btn {
|
||||
|
||||
border-radius: 30px;
|
||||
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);
|
||||
}
|
||||
|
||||
#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label {
|
||||
form .thumbnail-form-label {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -771,6 +825,10 @@ fieldset p, .section p {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-status {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* --- Stepper --- */
|
||||
|
||||
#stepper {
|
||||
@@ -905,6 +963,7 @@ justify-content: center;
|
||||
|
||||
#footer a {
|
||||
color: #fff;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.footer-credit .lum-first::before {
|
||||
@@ -920,6 +979,7 @@ justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: flex;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
@@ -994,12 +1054,12 @@ justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#stepper {
|
||||
flex-direction: column;
|
||||
form button[type="button"], form button[type="submit"], #stepper li {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#stepper li {
|
||||
width: 100%;
|
||||
#stepper {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-container, .footer-links {
|
||||
@@ -1020,14 +1080,16 @@ justify-content: center;
|
||||
/* Hide the default arrow */
|
||||
color: transparent;
|
||||
}
|
||||
#site-info-form, #theme-editor-form {
|
||||
padding: 18px 8px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
min-width: 100%;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#color-picker .input-field{
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@
|
||||
<span id="build-success-modal-close" class="modal-close">×</span>
|
||||
<h3>✅ Build completed!</h3>
|
||||
<p>Your files are available in the output folder.</p>
|
||||
<button id="download-zip-btn" class="modal-btn">Download ZIP</button>
|
||||
<div 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>
|
||||
</div>
|
||||
@@ -65,7 +68,7 @@
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
|
||||
<a class="footer-link gitea" href="https://gitea.com/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
|
||||
<a class="footer-link 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,116 +9,133 @@
|
||||
<div class="theme-info">
|
||||
<strong>Current theme:</strong> <span id="current-theme"></span>
|
||||
</div>
|
||||
<form id="theme-editor-form">
|
||||
<div id="theme-editor-form">
|
||||
<!-- Colors Section -->
|
||||
<form id="colors-form" autocomplete="off">
|
||||
<fieldset id="color-picker">
|
||||
<h2>Colors</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Set the color values for your theme</p>
|
||||
<div class="fields">
|
||||
<!-- Example for one color field, repeat for all -->
|
||||
<div class="input-field">
|
||||
<label>Primary</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-primary-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.primary" id="color-primary" class="color-input">
|
||||
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;">
|
||||
<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;" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Primary Dark</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input">
|
||||
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
|
||||
<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;" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Secondary</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-secondary-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.secondary" id="color-secondary" class="color-input">
|
||||
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
|
||||
<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;" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Accent</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-accent-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.accent" id="color-accent" class="color-input">
|
||||
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
|
||||
<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;" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Text Dark</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input">
|
||||
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
|
||||
<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;" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Background</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-background-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.background" id="color-background" class="color-input">
|
||||
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
|
||||
<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;" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<label>Browser Color</label>
|
||||
<div class="fields color-fields">
|
||||
<button type="button" id="color-browser-color-btn" class="color-btn"></button>
|
||||
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input">
|
||||
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
|
||||
<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;" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="colors">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<!-- Google Fonts Section -->
|
||||
<form id="google-fonts-form" autocomplete="off">
|
||||
<fieldset>
|
||||
<h2>Google Fonts</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Add Google Fonts to your theme</p>
|
||||
<div class="fields" id="google-fonts-fields">
|
||||
<!-- JS will render font family and weights inputs here -->
|
||||
</div>
|
||||
<button type="button" id="add-google-font">Add Google Font</button>
|
||||
<button type="submit" class="section-save-btn" data-section="google-fonts">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<!-- Custom Font Upload Section -->
|
||||
<form id="font-upload-form" autocomplete="off">
|
||||
<fieldset>
|
||||
<h2>Upload Custom Font</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Supported formats: .woff, .woff2</p>
|
||||
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
|
||||
<div id="local-fonts-list" class="font-list"></div>
|
||||
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
|
||||
<!-- Save button not needed for upload-only section -->
|
||||
</fieldset>
|
||||
</form>
|
||||
<!-- Fonts Section -->
|
||||
<form id="fonts-form" autocomplete="off">
|
||||
<fieldset>
|
||||
<h2>Fonts</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Select where to apply your fonts</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
<label>Primary Font</label>
|
||||
<select name="fonts.primary.name" id="font-primary"></select>
|
||||
<select name="fonts.primary.name" id="font-primary" required></select>
|
||||
<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="serif">serif</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="input-field">
|
||||
<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>
|
||||
<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="serif">serif</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="fonts">Save</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
<!-- Favicon Section -->
|
||||
<form id="favicon-form" autocomplete="off">
|
||||
<fieldset>
|
||||
<h2>Favicon</h2>
|
||||
<p class="section-status"></p>
|
||||
<p>Supported formats: .png, .jpg, .jpeg</p>
|
||||
<div class="fields">
|
||||
<div class="input-field">
|
||||
@@ -132,9 +149,11 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="section-save-btn" data-section="favicon">Save</button>
|
||||
</fieldset>
|
||||
<button type="submit">Save Theme</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Stepper -->
|
||||
<div class="section">
|
||||
<h2>Steps</h2>
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Delete confirmation modal for favicon -->
|
||||
<div id="delete-favicon-modal" class="modal" style="display:none;">
|
||||
<div class="modal-content">
|
||||
|
||||
Reference in New Issue
Block a user