30 Commits

Author SHA1 Message Date
116a9574ce Merge branch 'Beta-2.1' 2025-09-04 11:23:59 +00:00
6c7216c0e1 Hotfix - default copy 2025-09-04 13:18:42 +02:00
7c98a6ceaf Merge pull request 'Beta-2.1 - The clearer, the faster' (#22) from Beta-2.1 into main
Reviewed-on: #22
2025-09-04 12:50:26 +02:00
69d303c7a1 Refactored site-info 2025-09-04 12:28:01 +02:00
1d1d7d8304 Refactored Galley Editor 2025-09-04 12:22:52 +02:00
205dcae2bc Refactored upload.js 2025-09-04 12:16:08 +02:00
b7fdcacf77 Fixed wrong focus and suggestion drop down delay 2025-09-04 12:11:58 +02:00
9cdf6bbd32 Fixed hero images 2025-09-04 11:10:04 +02:00
f92060603d Fixed custom font issue 2025-09-04 10:41:58 +02:00
54e9281793 Fixed mobile theme css issue 2025-09-04 10:10:39 +02:00
5fb6ef18d1 Fixed log issues 2025-09-04 10:00:57 +02:00
1119647884 Fixed default file copy 2025-09-04 09:56:56 +02:00
2cc0a213c9 Fixed laggy menu appearance 2025-09-04 00:24:54 +02:00
24113a4aa8 Fixed build section status 2025-09-03 10:43:54 +02:00
be8ce4f62c Fixed message theme selector 2025-09-03 10:18:05 +02:00
edca473e29 New suggestion list css 2025-09-03 10:12:18 +02:00
eac90820d2 Fixed hero img fade in 2025-09-03 09:43:52 +02:00
191fa82711 Cleaning 2025-09-02 23:44:18 +02:00
b17652e471 Fade in img 2025-09-02 23:10:23 +02:00
65fd62e342 Untagged filter 2025-09-02 22:53:21 +02:00
6b03ee30fa Fixes save menu issues 2025-09-02 21:58:58 +02:00
fd45ebbd53 Fixed UI and conf template 2025-09-02 20:40:02 +02:00
795f5fbd13 Redone form save logic 2025-09-02 14:44:54 +02:00
f8bebb9c95 Fixed forms and key issues 2025-09-01 22:13:16 +00:00
3198755576 Top save button + fixed some CSS 2025-09-01 16:46:43 +00:00
f98f2d598f Fixed footer icon and git link 2025-09-01 16:16:27 +00:00
e8718e71ab Fixed port 2025-09-01 14:59:13 +00:00
5b65e5efe3 Better logging 2025-09-01 14:57:52 +00:00
021e0c7974 Preview link 2025-09-01 14:37:31 +00:00
debbf07280 New bottom upload btn + images count 2025-08-31 14:34:22 +02:00
24 changed files with 1308 additions and 909 deletions

1
.gitignore vendored
View File

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

View File

@ -1 +1 @@
2.0.1 2.1.0

View File

@ -1,4 +1,3 @@
# Please change this by your settings.
info: info:
title: title:
subtitle: subtitle:
@ -9,7 +8,7 @@ info:
social: social:
instagram_url: instagram_url:
thumbnail: thumbnail: ''
menu: menu:
items: items:
@ -18,19 +17,17 @@ menu:
footer: footer:
copyright: Copyright © 2025 copyright: Copyright © 2025
legal_link: '/legals/' legal_link: /legals/
legal_label: Legal notice legal_label: Legal notice
# Build parameters
build: build:
theme: modern # choose a theme in config/theme folder theme: modern
convert_images: true # true to enable image conversion convert_images: true
resize_images: true # true to enable image resizing resize_images: true
# Change this by your legals
legals: legals:
hoster_name: hoster_name:
hoster_adress: hoster_address:
hoster_contact: hoster_contact:
intellectual_property: intellectual_property:
- paragraph: "" - paragraph: ''

View File

@ -35,16 +35,13 @@ img, tag {
#footer { #footer {
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5); box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
margin: auto;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
#footer { #footer {
box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5); box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5);
border-radius: 15px 15px 0 0;
max-width: 1140px; max-width: 1140px;
margin: 0;
} }
.tag { .tag {
@ -53,7 +50,7 @@ img, tag {
.hero-background { .hero-background {
max-width: 90%; max-width: 90%;
margin: auto; margin: 0 auto;
} }
.back-button { .back-button {

View File

@ -1,32 +1,31 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
secondary: '#00b0f0' secondary: '#00B0F0'
accent: '#ffc700' accent: '#FFC700'
text_dark: '#616161' text_dark: '#616161'
background: '#ffffff' background: '#FFFFFF'
browser_color: '#ffffff' browser_color: '#FFFFFF'
favicon: favicon:
path: favicon.png path: favicon.png
google_fonts:
- family: Lato
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
fonts: fonts:
primary: primary:
name: Lato
fallback: sans-serif fallback: sans-serif
name: Lato
secondary: secondary:
name: Montserrat
fallback: serif fallback: serif
name: Montserrat
google_fonts:
- family: 'Lato'
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'

View File

@ -1,21 +1,17 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
secondary: '#00b0f0' secondary: '#00B0F0'
accent: '#ffc700'
text_dark: '#616161' text_dark: '#616161'
background: '#ffffff'
browser_color: '#ffffff'
favicon: favicon:
path: favicon.png path: favicon.png
fonts: fonts:
primary: primary:
name: Trixie name: trixie
fallback: sans-serif fallback: sans-serif
secondary: secondary:
name: Trixie name: trixie
fallback: serif fallback: serif

View File

@ -1,32 +1,31 @@
#-----------------------------------#
# Modern theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
secondary: '#00b0f0' secondary: '#00B0F0'
accent: '#ffc700' accent: '#FFC700'
text_dark: '#616161' text_dark: '#616161'
background: '#fff' background: '#FFFFFF'
browser_color: '#fff' browser_color: '#FFFFFF'
favicon: favicon:
path: favicon.png path: favicon.png
google_fonts:
- family: Lato
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'
fonts: fonts:
primary: primary:
name: Lato
fallback: sans-serif fallback: sans-serif
name: Lato
secondary: secondary:
name: Montserrat
fallback: serif fallback: serif
name: Montserrat
google_fonts:
- family: ''
weights:
- '200'
- '400'
- '700'
- family: Montserrat
weights:
- '200'
- '400'
- '700'

View File

@ -1,21 +1,19 @@
#-----------------------------------#
# Typewriter theme for Lumeex #
# https://git.djeex.fr/Djeex/lumeex #
#-----------------------------------#
colors: colors:
primary: '#0065a1' accent: '#FFC700'
background: '#FFFFFF'
browser_color: '#FFFFFF'
primary: '#0065A1'
primary_dark: '#005384' primary_dark: '#005384'
secondary: '#00b0f0' secondary: '#00B0F0'
accent: '#ffc700'
text_dark: '#616161' text_dark: '#616161'
background: '#fff'
browser_color: '#fff'
favicon: favicon:
path: favicon.png path: favicon.png
fonts: fonts:
primary: primary:
name: Trixie name: trixie.woff
fallback: sans-serif fallback: sans-serif
secondary: secondary:
name: Trixie name: trixie.woff
fallback: serif fallback: serif

2
docker/.env Normal file
View File

@ -0,0 +1,2 @@
PREVIEW_PORT=3000
WEBUI_PORT=5000

View File

@ -5,29 +5,32 @@ CYAN="\033[1;36m"
NC="\033[0m" NC="\033[0m"
copy_default_config() { copy_default_config() {
echo "Checking configuration directory..." echo "[~] Checking configuration directory..."
if [ ! -d "/app/config" ]; then mkdir -p /app/config
mkdir -p /app/config
fi
echo "Checking if default config files need to be copied..." echo "[~] Checking if default config files/folders need to be copied..."
files_copied=false files_copied=false
for file in /app/default/*; do for src in $(find /app/default -mindepth 1); do
filename=$(basename "$file") relpath="${src#/app/default/}"
target="/app/config/$filename" target="/app/config/$relpath"
if [ ! -e "$target" ]; then if [ ! -e "$target" ]; then
echo "Copying default config file: $filename" echo "[→] Copying: $relpath"
cp -r "$file" "$target" if [ -d "$src" ]; then
mkdir -p "$target"
cp -r "$src/" "$target/"
else
mkdir -p "$(dirname "$target")"
cp "$src" "$target"
fi
files_copied=true files_copied=true
fi fi
done done
if [ "$files_copied" = true ]; then if [ "$files_copied" = true ]; then
echo "Default configuration files copied successfully." echo "[✓] Default configuration files/folders copied successfully."
else else
echo "No default files needed to be copied." echo "[✓] No default files/folders needed to be copied."
fi fi
} }
@ -43,11 +46,13 @@ start_server() {
cat /tmp/build_logs_fifo >&2 & cat /tmp/build_logs_fifo >&2 &
cat /tmp/build_logs_fifo2 >&2 & cat /tmp/build_logs_fifo2 >&2 &
echo "Starting preview HTTP server on port 3000..." PREVIEW_PORT="${PREVIEW_PORT:-3000}"
echo "[~]Starting preview HTTP server on port 3000..."
echo "[i] Preview host port is set to: ${PREVIEW_PORT}"
python3 -u -m http.server 3000 -d /app/output & python3 -u -m http.server 3000 -d /app/output &
SERVER_PID=$! SERVER_PID=$!
echo "Starting Lumeex Flask webui..." echo "[~] Starting Lumeex Flask webui..."
python3 -u -m src.py.webui.webui & python3 -u -m src.py.webui.webui &
WEBUI_PID=$! WEBUI_PID=$!
@ -72,15 +77,15 @@ fi
case "$1" in case "$1" in
build) build)
echo "Running build.py..." echo "[~] Running build.py..."
python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo
;; ;;
gallery) gallery)
echo "Running gallery.py..." echo "[~] Running gallery.py..."
python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2 python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2
;; ;;
*) *)
echo "Unknown command: $1" echo "[!] Unknown command: $1"
exec "$@" exec "$@"
;; ;;
esac esac

View File

@ -2,10 +2,15 @@ services:
lumeex: lumeex:
container_name: lmx container_name: lmx
build: .. build: ..
env_file:
- .env
environment:
- PREVIEW_PORT=${PREVIEW_PORT:-3000} # port for preview server - set it in .env file
- WEBUI_PORT=${WEBUI_PORT:-5000} # port for webui server - set it in .env file
volumes: volumes:
- ../config:/app/config # mount config directory - ../config:/app/config # mount config directory
- ../output:/app/output # mount output directory - ../output:/app/output # mount output directory
ports: ports:
- "3000:3000" - "${PREVIEW_PORT:-3000}:3000"
- "5000:5000" - "${WEBUI_PORT:-5000}:5000"

View File

@ -9,7 +9,6 @@ window.addEventListener("DOMContentLoaded", () => {
entries.forEach(entry => { entries.forEach(entry => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
const img = entry.target; const img = entry.target;
console.log("Lazy-loading image:", img.dataset.src);
img.src = img.dataset.src; img.src = img.dataset.src;
img.onload = () => { img.onload = () => {
img.classList.add("loaded"); img.classList.add("loaded");

View File

@ -78,6 +78,8 @@ html,body {
font-weight: 400; font-weight: 400;
line-height:1.5; line-height:1.5;
color:var(--color-primary-dark); color:var(--color-primary-dark);
display: flex;
flex-direction: column;
} }
html { html {
@ -179,8 +181,8 @@ h2 {
/* animation */ /* animation */
.appear { .appear {
-webkit-transition: all 0.3s; -webkit-transition: all 1s;
transition: all 0.3s; transition: all 1s;
opacity: 0; opacity: 0;
-webkit-transform: translateY(20px); -webkit-transform: translateY(20px);
transform: translateY(20px); transform: translateY(20px);
@ -237,12 +239,23 @@ h2 {
/* Hero */ /* Hero */
#hero { #hero {
height: 100%; min-height: 100vh;
width: 100%; 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 { #hero .content-wrapper, #hero .section {
height:100%; height:100%;
} }
.hero-background { .hero-background {
height: 66%; height: 66%;
width: 100%; width: 100%;
@ -303,7 +316,6 @@ h2 {
font-size: 22px; font-size: 22px;
} }
.gallery { .gallery {
padding-top: 15px; padding-top: 15px;
} }
@ -341,6 +353,11 @@ h2 {
/* Footer */ /* Footer */
#footer {
margin: auto 0 0 0;
width: 100%;
}
.navigation { .navigation {
text-align: center; text-align: center;
padding-top: 40px; padding-top: 40px;
@ -470,8 +487,7 @@ h2 {
#legals.content-wrapper { #legals.content-wrapper {
max-width: 90%; max-width: 90%;
margin: auto; margin: 50px auto;
margin-top: 50px;
} }
.legals-content { .legals-content {

View File

@ -30,6 +30,8 @@ app = Flask(
static_url_path="" static_url_path=""
) )
WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000))
# --- Config paths --- # --- Config paths ---
SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml" SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos" PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos"
@ -72,9 +74,14 @@ def get_local_fonts(theme_name):
def index(): def index():
return render_template("index.html") return render_template("index.html")
PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000))
@app.context_processor @app.context_processor
def inject_version(): def inject_version():
return dict(lumeex_version=lumeex_version) return dict(
lumeex_version=lumeex_version,
preview_port=PREVIEW_PORT
)
# --- Gallery & Hero API --- # --- Gallery & Hero API ---
@app.route("/gallery-editor") @app.route("/gallery-editor")
@ -203,9 +210,21 @@ def get_site_info():
@app.route("/api/site-info", methods=["POST"]) @app.route("/api/site-info", methods=["POST"])
def update_site_info(): def update_site_info():
"""Update site info YAML.""" """Update site info YAML."""
data = request.json new_data = request.json
with open(SITE_YAML, "r") as f:
old_data = yaml.safe_load(f) or {}
def deep_merge(old, new):
for k, v in new.items():
if isinstance(v, dict) and isinstance(old.get(k), dict):
old[k] = deep_merge(old[k], v)
else:
old[k] = v
return old
merged = deep_merge(old_data, new_data)
with open(SITE_YAML, "w") as f: with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
# --- Theme management --- # --- Theme management ---
@ -396,7 +415,8 @@ def upload_font():
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True) fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename) file.save(fonts_dir / file.filename)
return jsonify({"status": "ok", "filename": file.filename}) font_basename = Path(file.filename).stem
return jsonify({"status": "ok", "filename": font_basename})
@app.route("/api/font/remove", methods=["POST"]) @app.route("/api/font/remove", methods=["POST"])
def remove_font(): def remove_font():
@ -487,5 +507,6 @@ def download_output_zip():
# --- Run server --- # --- Run server ---
if __name__ == "__main__": if __name__ == "__main__":
logging.info("Starting WebUI at http://0.0.0.0:5000") logging.info("[~] Starting WebUI at http://0.0.0.0:5000")
logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}")
app.run(host="0.0.0.0", port=5000, debug=True) app.run(host="0.0.0.0", port=5000, debug=True)

View File

@ -18,7 +18,16 @@
<button id="remove-all-hero" class="up-btn">🗑 Delete all</button> <button id="remove-all-hero" class="up-btn">🗑 Delete all</button>
</div> </div>
<input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden> <input type="file" id="upload-hero" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<span id="hero-count" class="photo-count"></span>
<div id="hero"></div> <div id="hero"></div>
<input type="file" id="upload-hero-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-hero-upload" class="upload-actions-row bottom-action-row">
<span id="hero-count-bottom" class="photo-count"></span>
<label for="upload-hero-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-hero-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div> </div>
<!-- Gallery Upload Section --> <!-- Gallery Upload Section -->
@ -31,8 +40,27 @@
</label> </label>
<button id="remove-all-gallery" class="up-btn">🗑 Delete all</button> <button id="remove-all-gallery" class="up-btn">🗑 Delete all</button>
</div> </div>
<div class="filter-row">
<label>
<input type="radio" name="gallery-filter" id="show-all-radio" checked>
All
</label>
<label>
<input type="radio" name="gallery-filter" id="show-untagged-radio">
Untagged
</label>
</div>
<span id="gallery-count" class="photo-count"></span>
<input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden> <input type="file" id="upload-gallery" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="gallery"></div> <div id="gallery"></div>
<input type="file" id="upload-gallery-bottom" accept=".png,.jpg,.jpeg,.webp" multiple hidden>
<div id="bottom-gallery-upload" class="upload-actions-row bottom-action-row">
<span id="gallery-count-bottom" class="photo-count"></span>
<label for="upload-gallery-bottom" class="up-btn">
📸 Upload photos
</label>
<button id="remove-all-gallery-bottom" class="up-btn bottom-remove-btn">🗑 Delete all</button>
</div>
</div> </div>
<div class="section"> <div class="section">

View File

@ -92,6 +92,18 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
} }
// Preview Site button
const previewBtn = document.getElementById("preview-site-btn");
if (previewBtn) {
const previewPort = previewBtn.getAttribute("data-preview-port") || "3000";
previewBtn.onclick = () => {
const host = window.location.hostname;
const protocol = window.location.protocol;
const url = `${protocol}//${host}:${previewPort}/`;
window.open(url, "_blank");
};
}
// Modal close logic // Modal close logic
if (buildModal && buildModalClose) { if (buildModal && buildModalClose) {
buildModalClose.onclick = () => { buildModalClose.onclick = () => {

View File

@ -1,18 +1,29 @@
// --- Arrays to store gallery and hero images ---
let galleryImages = []; let galleryImages = [];
let heroImages = []; let heroImages = [];
let allTags = []; // global tag list let allTags = [];
let showOnlyUntagged = false;
// --- Fade-in helper ---
function applyFadeInImages(container) {
container.querySelectorAll("img.fade-in-img").forEach(img => {
const onLoad = () => img.classList.add("loaded");
if (img.complete && img.naturalHeight !== 0) onLoad();
else {
img.addEventListener("load", onLoad, { once: true });
img.addEventListener("error", () => {
console.warn("Image failed to load:", img.dataset?.src || img.src);
});
}
});
}
// --- Load images from server on page load --- // --- Load images from server on page load ---
async function loadData() { async function loadData() {
try { try {
const galleryRes = await fetch('/api/gallery'); galleryImages = await (await fetch('/api/gallery')).json();
galleryImages = await galleryRes.json();
updateAllTags(); updateAllTags();
renderGallery(); renderGallery();
heroImages = await (await fetch('/api/hero')).json();
const heroRes = await fetch('/api/hero');
heroImages = await heroRes.json();
renderHero(); renderHero();
} catch(err) { } catch(err) {
console.error(err); console.error(err);
@ -24,22 +35,43 @@ async function loadData() {
function updateAllTags() { function updateAllTags() {
allTags = []; allTags = [];
galleryImages.forEach(img => { galleryImages.forEach(img => {
if (img.tags) img.tags.forEach(t => { (img.tags || []).forEach(t => {
if (!allTags.includes(t)) allTags.push(t); if (!allTags.includes(t)) allTags.push(t);
}); });
}); });
} }
// --- Helper: update count and button visibility ---
function updateCountAndButtons(prefix, count) {
const countTop = document.getElementById(`${prefix}-count`);
const countBottom = document.getElementById(`${prefix}-count-bottom`);
if (countTop) countTop.innerHTML = `<p>${count} photos</p>`;
if (countBottom) countBottom.innerHTML = `<p>${count} photos</p>`;
const removeAllBtn = document.getElementById(`remove-all-${prefix}`);
const removeAllBtnBottom = document.getElementById(`remove-all-${prefix}-bottom`);
if (removeAllBtn) removeAllBtn.style.display = count > 0 ? 'inline-block' : 'none';
if (removeAllBtnBottom) removeAllBtnBottom.style.display = count > 0 ? 'inline-block' : 'none';
const bottomUpload = document.getElementById(`bottom-${prefix}-upload`);
if (bottomUpload) bottomUpload.style.display = count > 0 ? 'flex' : 'none';
}
// --- Render gallery images with tags and delete buttons --- // --- Render gallery images with tags and delete buttons ---
function renderGallery() { function renderGallery() {
const container = document.getElementById('gallery'); const container = document.getElementById('gallery');
container.innerHTML = ''; container.innerHTML = '';
galleryImages.forEach((img, i) => { let imagesToShow = showOnlyUntagged
? galleryImages.filter(img => !img.tags || img.tags.length === 0)
: galleryImages;
imagesToShow.forEach((img) => {
const i = galleryImages.indexOf(img);
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'photo flex-item flex-column'; div.className = 'photo flex-item flex-column';
div.innerHTML = ` div.innerHTML = `
<div class="flex-item"> <div class="flex-item">
<img src="/photos/${img.src}"> <img class="fade-in-img" src="/photos/${img.src}">
</div> </div>
<div class="tags-display" data-index="${i}"></div> <div class="tags-display" data-index="${i}"></div>
<div class="flex-item flex-full"> <div class="flex-item flex-full">
@ -50,22 +82,17 @@ function renderGallery() {
</div> </div>
`; `;
container.appendChild(div); container.appendChild(div);
renderTags(i, img.tags || []); renderTags(i, img.tags || []);
}); });
// Show/hide Remove All button updateCountAndButtons('gallery', imagesToShow.length);
const removeAllBtn = document.getElementById('remove-all-gallery'); applyFadeInImages(container);
if (removeAllBtn) {
removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none';
}
} }
// --- Render tags for a single image --- // --- Render tags for a single image ---
function renderTags(imgIndex, tags) { function renderTags(imgIndex, tags) {
const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`); const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`);
const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`); const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`);
tagsDisplay.innerHTML = ''; tagsDisplay.innerHTML = '';
inputContainer.innerHTML = ''; inputContainer.innerHTML = '';
@ -73,7 +100,6 @@ function renderTags(imgIndex, tags) {
const span = document.createElement('span'); const span = document.createElement('span');
span.className = 'tag'; span.className = 'tag';
span.textContent = tag; span.textContent = tag;
const remove = document.createElement('span'); const remove = document.createElement('span');
remove.className = 'remove-tag'; remove.className = 'remove-tag';
remove.textContent = '×'; remove.textContent = '×';
@ -82,7 +108,6 @@ function renderTags(imgIndex, tags) {
updateTags(imgIndex, tags); updateTags(imgIndex, tags);
renderTags(imgIndex, tags); renderTags(imgIndex, tags);
}; };
span.appendChild(remove); span.appendChild(remove);
tagsDisplay.appendChild(span); tagsDisplay.appendChild(span);
}); });
@ -92,11 +117,10 @@ function renderTags(imgIndex, tags) {
input.placeholder = 'Add tag...'; input.placeholder = 'Add tag...';
inputContainer.appendChild(input); inputContainer.appendChild(input);
// --- Validate button ---
const validateBtn = document.createElement('button'); const validateBtn = document.createElement('button');
validateBtn.textContent = '✔️'; validateBtn.textContent = '✔️';
validateBtn.className = 'validate-tag-btn'; validateBtn.className = 'validate-tag-btn';
validateBtn.style.display = 'none'; // hidden by default validateBtn.style.display = 'none';
validateBtn.style.marginLeft = '4px'; validateBtn.style.marginLeft = '4px';
inputContainer.appendChild(validateBtn); inputContainer.appendChild(validateBtn);
@ -116,30 +140,20 @@ function renderTags(imgIndex, tags) {
const updateSuggestions = () => { const updateSuggestions = () => {
const value = input.value.toLowerCase(); const value = input.value.toLowerCase();
const allTagsFlat = galleryImages.flatMap(img => img.tags || []); const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
const tagCount = {}; const tagCount = {};
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1); allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
const allTagsSorted = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
const allTagsSorted = Object.keys(tagCount)
.sort((a, b) => tagCount[b] - tagCount[a]);
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t)); const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
suggestionBox.innerHTML = ''; suggestionBox.innerHTML = '';
selectedIndex = -1; selectedIndex = -1;
if (suggestions.length) { if (suggestions.length) {
suggestionBox.style.display = 'block'; suggestionBox.style.display = 'block';
suggestions.forEach((s, idx) => { suggestions.forEach((s, idx) => {
const li = document.createElement('li'); const li = document.createElement('li');
li.style.fontStyle = 'italic'; li.style.fontStyle = 'italic';
li.style.textAlign = 'left'; li.style.textAlign = 'left';
li.innerHTML = `<b>${s.substring(0, input.value.length)}</b>${s.substring(input.value.length)}`;
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
const rest = s.substring(input.value.length);
li.innerHTML = boldPart + rest;
li.addEventListener('mousedown', (e) => { li.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
addTag(s); addTag(s);
@ -147,7 +161,6 @@ function renderTags(imgIndex, tags) {
input.focus(); input.focus();
updateSuggestions(); updateSuggestions();
}); });
li.onmouseover = () => selectedIndex = idx; li.onmouseover = () => selectedIndex = idx;
suggestionBox.appendChild(li); suggestionBox.appendChild(li);
}); });
@ -164,7 +177,6 @@ function renderTags(imgIndex, tags) {
updateSuggestions(); updateSuggestions();
validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none'; validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none';
}); });
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
const items = suggestionBox.querySelectorAll('li'); const items = suggestionBox.querySelectorAll('li');
if (e.key === 'ArrowDown') { if (e.key === 'ArrowDown') {
@ -195,27 +207,22 @@ function renderTags(imgIndex, tags) {
validateBtn.style.display = 'none'; validateBtn.style.display = 'none';
} }
}); });
input.addEventListener('blur', () => { input.addEventListener('blur', () => {
setTimeout(() => { suggestionBox.style.display = 'none';
suggestionBox.style.display = 'none'; input.value = '';
input.value = ''; validateBtn.style.display = 'none';
validateBtn.style.display = 'none';
}, 150);
}); });
validateBtn.addEventListener('mousedown', (e) => {
// --- Validate button action --- e.preventDefault();
validateBtn.onclick = () => {
if (input.value.trim()) { if (input.value.trim()) {
addTag(input.value.trim()); addTag(input.value.trim());
input.value = ''; input.value = '';
updateSuggestions(); updateSuggestions();
validateBtn.style.display = 'none'; validateBtn.style.display = 'none';
} }
}; });
input.focus();
updateSuggestions(); updateSuggestions();
if (!input.value.trim()) suggestionBox.style.display = 'none';
} }
// --- Update tags in galleryImages array --- // --- Update tags in galleryImages array ---
@ -233,7 +240,7 @@ function renderHero() {
div.className = 'photo flex-item flex-column'; div.className = 'photo flex-item flex-column';
div.innerHTML = ` div.innerHTML = `
<div class="flex-item"> <div class="flex-item">
<img src="/photos/${img.src}"> <img class="fade-in-img" src="/photos/${img.src}">
</div> </div>
<div class="flex-item flex-full"> <div class="flex-item flex-full">
<div class="flex-item flex-end"> <div class="flex-item flex-end">
@ -243,12 +250,8 @@ function renderHero() {
`; `;
container.appendChild(div); container.appendChild(div);
}); });
updateCountAndButtons('hero', heroImages.length);
// Show/hide Remove All button applyFadeInImages(container);
const removeAllBtn = document.getElementById('remove-all-hero');
if (removeAllBtn) {
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
}
} }
// --- Save gallery to server --- // --- Save gallery to server ---
@ -294,33 +297,27 @@ async function refreshHero() {
function showToast(message, type = "success", duration = 3000) { function showToast(message, type = "success", duration = 3000) {
const container = document.getElementById("toast-container"); const container = document.getElementById("toast-container");
if (!container) return; if (!container) return;
const toast = document.createElement("div"); const toast = document.createElement("div");
toast.className = `toast ${type}`; toast.className = `toast ${type}`;
toast.textContent = message; toast.textContent = message;
container.appendChild(toast); container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show")); requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => { setTimeout(() => {
toast.classList.remove("show"); toast.classList.remove("show");
toast.addEventListener("transitionend", () => toast.remove()); toast.addEventListener("transitionend", () => toast.remove());
}, duration); }, duration);
} }
let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null } let pendingDelete = null;
// --- Show delete confirmation modal --- // --- Show delete confirmation modal ---
function showDeleteModal(type, index = null) { function showDeleteModal(type, index = null) {
pendingDelete = { type, index }; pendingDelete = { type, index };
const modalText = document.getElementById('delete-modal-text'); const modalText = document.getElementById('delete-modal-text');
if (type === 'gallery-all') { modalText.textContent =
modalText.textContent = "Are you sure you want to delete ALL gallery images?"; type === 'gallery-all' ? "Are you sure you want to delete ALL gallery images?"
} else if (type === 'hero-all') { : type === 'hero-all' ? "Are you sure you want to delete ALL hero images?"
modalText.textContent = "Are you sure you want to delete ALL hero images?"; : "Are you sure you want to delete this image?";
} else {
modalText.textContent = "Are you sure you want to delete this image?";
}
document.getElementById('delete-modal').style.display = 'flex'; document.getElementById('delete-modal').style.display = 'flex';
} }
@ -333,15 +330,10 @@ function hideDeleteModal() {
// --- Confirm deletion --- // --- Confirm deletion ---
async function confirmDelete() { async function confirmDelete() {
if (!pendingDelete) return; if (!pendingDelete) return;
if (pendingDelete.type === 'gallery') { if (pendingDelete.type === 'gallery') await actuallyDeleteGalleryImage(pendingDelete.index);
await actuallyDeleteGalleryImage(pendingDelete.index); else if (pendingDelete.type === 'hero') await actuallyDeleteHeroImage(pendingDelete.index);
} else if (pendingDelete.type === 'hero') { else if (pendingDelete.type === 'gallery-all') await actuallyDeleteAllGalleryImages();
await actuallyDeleteHeroImage(pendingDelete.index); else if (pendingDelete.type === 'hero-all') await actuallyDeleteAllHeroImages();
} else if (pendingDelete.type === 'gallery-all') {
await actuallyDeleteAllGalleryImages();
} else if (pendingDelete.type === 'hero-all') {
await actuallyDeleteAllHeroImages();
}
hideDeleteModal(); hideDeleteModal();
} }
@ -423,15 +415,35 @@ async function actuallyDeleteAllHeroImages() {
// --- Modal event listeners and bulk delete buttons --- // --- Modal event listeners and bulk delete buttons ---
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
document.getElementById('delete-modal-close').onclick = hideDeleteModal; ['delete-modal-close', 'delete-modal-cancel'].forEach(id => {
document.getElementById('delete-modal-cancel').onclick = hideDeleteModal; const el = document.getElementById(id);
document.getElementById('delete-modal-confirm').onclick = confirmDelete; if (el) el.onclick = hideDeleteModal;
});
const confirmBtn = document.getElementById('delete-modal-confirm');
if (confirmBtn) confirmBtn.onclick = confirmDelete;
// Gallery filter radios
const showAllRadio = document.getElementById('show-all-radio');
const showUntaggedRadio = document.getElementById('show-untagged-radio');
if (showAllRadio) showAllRadio.addEventListener('change', () => {
showOnlyUntagged = false;
renderGallery();
});
if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => {
showOnlyUntagged = true;
renderGallery();
});
// Bulk delete buttons // Bulk delete buttons
const removeAllGalleryBtn = document.getElementById('remove-all-gallery'); [
const removeAllHeroBtn = document.getElementById('remove-all-hero'); ['remove-all-gallery', 'gallery-all'],
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all'); ['remove-all-gallery-bottom', 'gallery-all'],
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all'); ['remove-all-hero', 'hero-all'],
['remove-all-hero-bottom', 'hero-all']
].forEach(([btnId, type]) => {
const btn = document.getElementById(btnId);
if (btn) btn.onclick = () => showDeleteModal(type);
});
}); });
// --- Initialize --- // --- Initialize ---

View File

@ -12,7 +12,6 @@ function showToast(message, type = "success", duration = 3000) {
}, duration); }, duration);
} }
// --- Loader helpers ---
function showLoader(text = "Uploading...") { function showLoader(text = "Uploading...") {
const loader = document.getElementById("global-loader"); const loader = document.getElementById("global-loader");
if (loader) { if (loader) {
@ -26,119 +25,99 @@ function hideLoader() {
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
// Form and menu logic // --- Section Forms ---
const form = document.getElementById("site-info-form"); const forms = {
info: document.getElementById("info-form"),
social: document.getElementById("social-form"),
menu: document.getElementById("menu-form"),
footer: document.getElementById("footer-form"),
legals: document.getElementById("legals-form"),
build: document.getElementById("build-form")
};
// --- Menu logic ---
const menuList = document.getElementById("menu-items-list"); const menuList = document.getElementById("menu-items-list");
const addMenuBtn = document.getElementById("add-menu-item"); const addMenuBtn = document.getElementById("add-menu-item");
let menuItems = []; let menuItems = [];
// Render menu items
function renderMenuItems() { function renderMenuItems() {
menuList.innerHTML = ""; menuList.innerHTML = "";
menuItems.forEach((item, idx) => { menuItems.forEach((item, idx) => {
const div = document.createElement("div"); menuList.innerHTML += `
div.style.display = "flex"; <div style="display:flex;gap:8px;margin-bottom:6px;">
div.style.gap = "8px"; <input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
div.style.marginBottom = "6px"; <input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
div.innerHTML = ` <button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required> </div>
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
`; `;
menuList.appendChild(div);
}); });
} }
// Update menu items from inputs
function updateMenuItemsFromInputs() { function updateMenuItemsFromInputs() {
const inputs = menuList.querySelectorAll("input"); const inputs = menuList.querySelectorAll("input");
const items = []; menuItems = [];
for (let i = 0; i < inputs.length; i += 2) { for (let i = 0; i < inputs.length; i += 2) {
const label = inputs[i].value.trim(); const label = inputs[i].value.trim();
const href = inputs[i + 1].value.trim(); const href = inputs[i + 1].value.trim();
if (label || href) items.push({ label, href }); if (label || href) menuItems.push({ label, href });
} }
menuItems = items;
} }
// Intellectual property paragraphs logic // --- Intellectual property paragraphs logic ---
const ipList = document.getElementById("ip-list"); const ipList = document.getElementById("ip-list");
const addIpBtn = document.getElementById("add-ip-paragraph"); const addIpBtn = document.getElementById("add-ip-paragraph");
let ipParagraphs = []; let ipParagraphs = [];
// Render IP paragraphs
function renderIpParagraphs() { function renderIpParagraphs() {
ipList.innerHTML = ""; ipList.innerHTML = "";
ipParagraphs.forEach((item, idx) => { ipParagraphs.forEach((item, idx) => {
const div = document.createElement("div"); ipList.innerHTML += `
div.style.display = "flex"; <div style="display:flex;gap:8px;margin-bottom:6px;">
div.style.gap = "8px"; <textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
div.style.marginBottom = "6px"; <button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
div.innerHTML = ` </div>
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
`; `;
ipList.appendChild(div);
}); });
} }
// Update IP paragraphs from textareas
function updateIpParagraphsFromInputs() { function updateIpParagraphsFromInputs() {
const textareas = ipList.querySelectorAll("textarea"); ipParagraphs = Array.from(ipList.querySelectorAll("textarea"))
ipParagraphs = Array.from(textareas).map(textarea => ({ .map(textarea => ({ paragraph: textarea.value.trim() }))
paragraph: textarea.value.trim() .filter(item => item.paragraph !== "");
})).filter(item => item.paragraph !== "");
} }
// Build options // --- Build options & Theme select ---
const convertImagesCheckbox = document.getElementById("convert-images-checkbox"); const convertImagesCheckbox = document.getElementById("convert-images-checkbox");
const resizeImagesCheckbox = document.getElementById("resize-images-checkbox"); const resizeImagesCheckbox = document.getElementById("resize-images-checkbox");
// Theme select
const themeSelect = document.getElementById("theme-select"); const themeSelect = document.getElementById("theme-select");
// Thumbnail upload and modal logic // --- Thumbnail upload and modal logic ---
const thumbnailInput = form?.elements["social.thumbnail"]; const thumbnailInput = document.getElementById("social-thumbnail");
const thumbnailUpload = document.getElementById("thumbnail-upload"); const thumbnailUpload = document.getElementById("thumbnail-upload");
const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn"); const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn");
const thumbnailPreview = document.getElementById("thumbnail-preview"); const thumbnailPreview = document.getElementById("thumbnail-preview");
const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn"); const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn");
// Modal elements for delete confirmation // --- Modal helpers ---
const deleteModal = document.getElementById("delete-modal"); function setupModal(modal, closeBtn, confirmBtn, cancelBtn, onConfirm) {
const deleteModalClose = document.getElementById("delete-modal-close"); if (!modal) return;
const deleteModalConfirm = document.getElementById("delete-modal-confirm"); if (closeBtn) closeBtn.onclick = () => modal.style.display = "none";
const deleteModalCancel = document.getElementById("delete-modal-cancel"); if (cancelBtn) cancelBtn.onclick = () => modal.style.display = "none";
window.addEventListener("click", (e) => {
if (e.target === modal) modal.style.display = "none";
});
if (confirmBtn && onConfirm) confirmBtn.onclick = onConfirm;
}
// Modal elements for theme deletion // --- Thumbnail preview logic ---
const deleteThemeModal = document.getElementById("delete-theme-modal");
const deleteThemeModalClose = document.getElementById("delete-theme-modal-close");
const deleteThemeModalConfirm = document.getElementById("delete-theme-modal-confirm");
const deleteThemeModalCancel = document.getElementById("delete-theme-modal-cancel");
const deleteThemeModalText = document.getElementById("delete-theme-modal-text");
let themeToDelete = null;
// Show/hide thumbnail preview, remove button, and choose button
function updateThumbnailPreview(src) { function updateThumbnailPreview(src) {
if (thumbnailPreview) { if (thumbnailPreview) {
thumbnailPreview.src = src || ""; thumbnailPreview.src = src || "";
thumbnailPreview.style.display = src ? "block" : "none"; thumbnailPreview.style.display = src ? "block" : "none";
} }
if (removeThumbnailBtn) { if (removeThumbnailBtn) removeThumbnailBtn.style.display = src ? "inline-block" : "none";
removeThumbnailBtn.style.display = src ? "inline-block" : "none"; if (chooseThumbnailBtn) chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
}
if (chooseThumbnailBtn) {
chooseThumbnailBtn.style.display = src ? "none" : "inline-block";
}
} }
// Choose thumbnail button triggers file input
if (chooseThumbnailBtn && thumbnailUpload) { if (chooseThumbnailBtn && thumbnailUpload) {
chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click()); chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click());
} }
// Handle thumbnail upload and refresh preview (with cache busting)
if (thumbnailUpload) { if (thumbnailUpload) {
thumbnailUpload.addEventListener("change", async (e) => { thumbnailUpload.addEventListener("change", async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
@ -156,27 +135,20 @@ document.addEventListener("DOMContentLoaded", () => {
} else { } else {
showToast("❌ Error uploading thumbnail", "error"); showToast("❌ Error uploading thumbnail", "error");
} }
updateSectionStatus("social");
}); });
} }
// Remove thumbnail button triggers modal
if (removeThumbnailBtn) { if (removeThumbnailBtn) {
removeThumbnailBtn.addEventListener("click", () => { removeThumbnailBtn.addEventListener("click", () => {
deleteModal.style.display = "flex"; document.getElementById("delete-modal").style.display = "flex";
}); });
} }
setupModal(
// Modal logic for thumbnail deletion document.getElementById("delete-modal"),
if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) { document.getElementById("delete-modal-close"),
deleteModalClose.onclick = deleteModalCancel.onclick = () => { document.getElementById("delete-modal-confirm"),
deleteModal.style.display = "none"; document.getElementById("delete-modal-cancel"),
}; async () => {
window.onclick = function(event) {
if (event.target === deleteModal) {
deleteModal.style.display = "none";
}
};
deleteModalConfirm.onclick = async () => {
const res = await fetch("/api/thumbnail/remove", { method: "POST" }); const res = await fetch("/api/thumbnail/remove", { method: "POST" });
const result = await res.json(); const result = await res.json();
if (result.status === "ok") { if (result.status === "ok") {
@ -186,48 +158,38 @@ document.addEventListener("DOMContentLoaded", () => {
} else { } else {
showToast("❌ Error removing thumbnail", "error"); showToast("❌ Error removing thumbnail", "error");
} }
deleteModal.style.display = "none"; document.getElementById("delete-modal").style.display = "none";
}; updateSectionStatus("social");
} }
);
// Theme upload logic (custom theme folder) // --- Theme upload logic ---
const themeUpload = document.getElementById("theme-upload"); const themeUpload = document.getElementById("theme-upload");
const chooseThemeBtn = document.getElementById("choose-theme-btn"); const chooseThemeBtn = document.getElementById("choose-theme-btn");
if (chooseThemeBtn && themeUpload) { if (chooseThemeBtn && themeUpload) {
chooseThemeBtn.addEventListener("click", () => themeUpload.click()); chooseThemeBtn.addEventListener("click", () => themeUpload.click());
themeUpload.addEventListener("change", async (e) => { themeUpload.addEventListener("change", async (e) => {
const files = Array.from(e.target.files); const files = Array.from(e.target.files);
if (files.length === 0) return; if (!files.length) return;
showLoader("Uploading theme..."); showLoader("Uploading theme...");
const formData = new FormData(); const formData = new FormData();
files.forEach(file => { files.forEach(file => formData.append("files", file, file.webkitRelativePath || file.name));
formData.append("files", file, file.webkitRelativePath || file.name);
});
const res = await fetch("/api/theme/upload", { method: "POST", body: formData }); const res = await fetch("/api/theme/upload", { method: "POST", body: formData });
const result = await res.json(); const result = await res.json();
hideLoader(); hideLoader();
if (result.status === "ok") { if (result.status === "ok") {
showToast("✅ Theme uploaded!", "success"); showToast("✅ Theme uploaded!", "success");
// Refresh theme select after upload refreshThemes();
fetch("/api/themes")
.then(res => res.json())
.then(themes => {
themeSelect.innerHTML = "";
themes.forEach(theme => {
const option = document.createElement("option");
option.value = theme;
option.textContent = theme;
themeSelect.appendChild(option);
});
});
} else { } else {
showToast("❌ Error uploading theme", "error"); showToast("❌ Error uploading theme", "error");
} }
updateSectionStatus("build");
}); });
} }
// Remove theme button triggers modal // --- Remove theme logic ---
const removeThemeBtn = document.getElementById("remove-theme-btn"); const removeThemeBtn = document.getElementById("remove-theme-btn");
let themeToDelete = null;
if (removeThemeBtn && themeSelect) { if (removeThemeBtn && themeSelect) {
removeThemeBtn.addEventListener("click", () => { removeThemeBtn.addEventListener("click", () => {
const theme = themeSelect.value; const theme = themeSelect.value;
@ -237,24 +199,16 @@ document.addEventListener("DOMContentLoaded", () => {
return; return;
} }
themeToDelete = theme; themeToDelete = theme;
deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`; document.getElementById("delete-theme-modal-text").textContent = `Are you sure you want to remove theme "${theme}"?`;
deleteThemeModal.style.display = "flex"; document.getElementById("delete-theme-modal").style.display = "flex";
}); });
} }
setupModal(
// Modal logic for theme deletion document.getElementById("delete-theme-modal"),
if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) { document.getElementById("delete-theme-modal-close"),
deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => { document.getElementById("delete-theme-modal-confirm"),
deleteThemeModal.style.display = "none"; document.getElementById("delete-theme-modal-cancel"),
themeToDelete = null; async () => {
};
window.onclick = function(event) {
if (event.target === deleteThemeModal) {
deleteThemeModal.style.display = "none";
themeToDelete = null;
}
};
deleteThemeModalConfirm.onclick = async () => {
if (!themeToDelete) return; if (!themeToDelete) return;
showLoader("Removing theme..."); showLoader("Removing theme...");
const res = await fetch("/api/theme/remove", { const res = await fetch("/api/theme/remove", {
@ -266,28 +220,18 @@ document.addEventListener("DOMContentLoaded", () => {
hideLoader(); hideLoader();
if (result.status === "ok") { if (result.status === "ok") {
showToast("✅ Theme removed!", "success"); showToast("✅ Theme removed!", "success");
// Refresh theme select refreshThemes();
fetch("/api/themes")
.then(res => res.json())
.then(themes => {
themeSelect.innerHTML = "";
themes.forEach(theme => {
const option = document.createElement("option");
option.value = theme;
option.textContent = theme;
themeSelect.appendChild(option);
});
});
} else { } else {
showToast(result.error || "❌ Error removing theme", "error"); showToast(result.error || "❌ Error removing theme", "error");
} }
deleteThemeModal.style.display = "none"; document.getElementById("delete-theme-modal").style.display = "none";
themeToDelete = null; themeToDelete = null;
}; updateSectionStatus("build");
} }
);
// Fetch theme list and populate select // --- Theme select refresh ---
if (themeSelect) { function refreshThemes() {
fetch("/api/themes") fetch("/api/themes")
.then(res => res.json()) .then(res => res.json())
.then(themes => { .then(themes => {
@ -298,148 +242,276 @@ document.addEventListener("DOMContentLoaded", () => {
option.textContent = theme; option.textContent = theme;
themeSelect.appendChild(option); themeSelect.appendChild(option);
}); });
// Set selected value after loading config loadConfigAndUpdateBuildStatus();
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
themeSelect.value = data.build?.theme || "";
});
}); });
} }
// Load config from server and populate form // --- Config loading ---
if (form) { let loadedConfig = {};
function loadConfigAndUpdateBuildStatus() {
fetch("/api/site-info") fetch("/api/site-info")
.then(res => res.json()) .then(res => res.json())
.then(data => { .then(data => {
loadedConfig = data;
// Info
if (forms.info) {
forms.info.elements["info.title"].value = data.info?.title || "";
forms.info.elements["info.subtitle"].value = data.info?.subtitle || "";
forms.info.elements["info.description"].value = data.info?.description || "";
forms.info.elements["info.canonical"].value = data.info?.canonical || "";
forms.info.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
forms.info.elements["info.author"].value = data.info?.author || "";
}
// Social
if (forms.social) {
forms.social.elements["social.instagram_url"].value = data.social?.instagram_url || "";
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
}
// Menu
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
renderMenuItems();
// Footer
if (forms.footer) {
forms.footer.elements["footer.copyright"].value = data.footer?.copyright || "";
forms.footer.elements["footer.legal_label"].value = data.footer?.legal_label || "";
}
// Legals
ipParagraphs = Array.isArray(data.legals?.intellectual_property) ipParagraphs = Array.isArray(data.legals?.intellectual_property)
? data.legals.intellectual_property ? data.legals.intellectual_property
: []; : [];
renderIpParagraphs(); renderIpParagraphs();
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : []; if (forms.legals) {
renderMenuItems(); forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
form.elements["info.title"].value = data.info?.title || ""; forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
form.elements["info.subtitle"].value = data.info?.subtitle || ""; forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
form.elements["info.description"].value = data.info?.description || "";
form.elements["info.canonical"].value = data.info?.canonical || "";
form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
form.elements["info.author"].value = data.info?.author || "";
form.elements["social.instagram_url"].value = data.social?.instagram_url || "";
if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || "";
updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : "");
form.elements["footer.copyright"].value = data.footer?.copyright || "";
form.elements["footer.legal_label"].value = data.footer?.legal_label || "";
if (themeSelect) {
themeSelect.value = data.build?.theme || "";
}
form.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
form.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
// Build checkboxes
if (convertImagesCheckbox) {
convertImagesCheckbox.checked = !!data.build?.convert_images;
}
if (resizeImagesCheckbox) {
resizeImagesCheckbox.checked = !!data.build?.resize_images;
} }
// Build
if (themeSelect) themeSelect.value = data.build?.theme || "";
if (convertImagesCheckbox) convertImagesCheckbox.checked = !!data.build?.convert_images;
if (resizeImagesCheckbox) resizeImagesCheckbox.checked = !!data.build?.resize_images;
["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus);
updateSectionStatus("build");
}); });
} }
if (themeSelect) refreshThemes();
else loadConfigAndUpdateBuildStatus();
// Add menu item // --- Add/remove menu items ---
if (addMenuBtn) { if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
addMenuBtn.addEventListener("click", () => { menuItems.push({ label: "", href: "" });
menuItems.push({ label: "", href: "" }); renderMenuItems();
renderMenuItems(); updateSectionStatus("menu");
}); });
}
// Remove menu item
menuList.addEventListener("click", (e) => { menuList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-menu-item")) { if (e.target.classList.contains("remove-menu-item")) {
const idx = parseInt(e.target.getAttribute("data-idx")); const idx = parseInt(e.target.getAttribute("data-idx"));
menuItems.splice(idx, 1); menuItems.splice(idx, 1);
renderMenuItems(); renderMenuItems();
updateSectionStatus("menu");
} }
}); });
// Update menuItems on input change
menuList.addEventListener("input", () => { menuList.addEventListener("input", () => {
updateMenuItemsFromInputs(); updateMenuItemsFromInputs();
updateSectionStatus("menu");
}); });
// Add paragraph // --- Add/remove IP paragraphs ---
if (addIpBtn) { if (addIpBtn) addIpBtn.addEventListener("click", () => {
addIpBtn.addEventListener("click", () => { ipParagraphs.push({ paragraph: "" });
ipParagraphs.push({ paragraph: "" }); renderIpParagraphs();
renderIpParagraphs(); updateSectionStatus("legals");
}); });
}
// Remove paragraph
ipList.addEventListener("click", (e) => { ipList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-ip-paragraph")) { if (e.target.classList.contains("remove-ip-paragraph")) {
const idx = parseInt(e.target.getAttribute("data-idx")); const idx = parseInt(e.target.getAttribute("data-idx"));
ipParagraphs.splice(idx, 1); ipParagraphs.splice(idx, 1);
renderIpParagraphs(); renderIpParagraphs();
updateSectionStatus("legals");
} }
}); });
// Update ipParagraphs on input change
ipList.addEventListener("input", () => { ipList.addEventListener("input", () => {
updateIpParagraphsFromInputs(); updateIpParagraphsFromInputs();
updateSectionStatus("legals");
}); });
// Save config to server // --- Section value helpers ---
if (form) { function getSectionValues(section) {
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();
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)
};
default:
return {};
}
}
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) => { form.addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
updateMenuItemsFromInputs(); if (!form.reportValidity()) {
updateIpParagraphsFromInputs(); showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section);
// 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; return;
} }
if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) {
const build = { showToast("❌ Thumbnail is required.", "error");
theme: themeSelect ? themeSelect.value : "", updateSectionStatus(section);
convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked), return;
resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked) }
}; if (section === "menu") {
updateMenuItemsFromInputs();
const payload = { if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
info: { showToast("❌ Please fill all menu item fields.", "error");
title: form.elements["info.title"].value, updateSectionStatus(section);
subtitle: form.elements["info.subtitle"].value, return;
description: form.elements["info.description"].value,
canonical: form.elements["info.canonical"].value,
keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: form.elements["info.author"].value
},
social: {
instagram_url: form.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
},
menu: {
items: menuItems
},
footer: {
copyright: form.elements["footer.copyright"].value,
legal_label: form.elements["footer.legal_label"].value
},
build,
legals: {
hoster_name: form.elements["legals.hoster_name"].value,
hoster_address: form.elements["legals.hoster_address"].value,
hoster_contact: form.elements["legals.hoster_contact"].value,
intellectual_property: ipParagraphs
} }
}; }
// --- REMOVE loader for save --- if (section === "legals") {
// showLoader("Saving..."); updateIpParagraphsFromInputs();
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
showToast("❌ Please fill all intellectual property paragraphs.", "error");
updateSectionStatus(section);
return;
}
}
let payload = {};
payload[section] = getSectionValues(section);
const res = await fetch("/api/site-info", { const res = await fetch("/api/site-info", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -447,10 +519,16 @@ document.addEventListener("DOMContentLoaded", () => {
}); });
const result = await res.json(); const result = await res.json();
if (result.status === "ok") { if (result.status === "ok") {
showToast("✅ Site info saved!", "success"); showToast("✅ Section saved!", "success");
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
loadedConfig = data;
updateSectionStatus(section);
});
} else { } else {
showToast("❌ Error saving site info", "error"); showToast("❌ Error saving section", "error");
} }
}); });
} });
}); });

View File

@ -70,9 +70,11 @@ function setupColorPicker(colorId, btnId, textId, initial) {
function setFontDropdown(selectId, value, options) { function setFontDropdown(selectId, value, options) {
const select = document.getElementById(selectId); const select = document.getElementById(selectId);
if (!select) return; if (!select) return;
select.innerHTML = options.map(opt => select.innerHTML = options.map(opt => {
`<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>` // Remove extension if present
).join(""); const base = opt.replace(/\.(woff2?|ttf|otf)$/, "");
return `<option value="${base}"${base === value ? " selected" : ""}>${base}</option>`;
}).join("");
} }
function setFallbackDropdown(selectId, value) { function setFallbackDropdown(selectId, value) {
@ -116,54 +118,173 @@ function renderLocalFonts(fonts) {
}); });
} }
// --- Section helpers ---
function getSectionValues(section) {
switch (section) {
case "colors":
return {
primary: document.getElementById("color-primary-text").value,
primary_dark: document.getElementById("color-primary-dark-text").value,
secondary: document.getElementById("color-secondary-text").value,
accent: document.getElementById("color-accent-text").value,
text_dark: document.getElementById("color-text-dark-text").value,
background: document.getElementById("color-background-text").value,
browser_color: document.getElementById("color-browser-color-text").value
};
case "google-fonts":
const googleFontsFields = document.getElementById("google-fonts-fields");
const fonts = [];
if (googleFontsFields) {
googleFontsFields.querySelectorAll(".input-field").forEach(field => {
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value.trim();
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
.split(",").map(w => w.trim()).filter(Boolean);
if (family) fonts.push({ family, weights });
});
}
return fonts;
case "fonts":
return {
primary: {
name: document.getElementById("font-primary").value,
fallback: document.getElementById("font-primary-fallback").value
},
secondary: {
name: document.getElementById("font-secondary").value,
fallback: document.getElementById("font-secondary-fallback").value
}
};
case "favicon":
return {
path: document.getElementById("favicon-path").value
};
default:
return {};
}
}
function isSectionComplete(section) {
switch (section) {
case "colors":
const v = getSectionValues("colors");
return (
v.primary &&
v.primary_dark &&
v.secondary &&
v.accent &&
v.text_dark &&
v.background &&
v.browser_color
);
case "google-fonts":
const fonts = getSectionValues("google-fonts");
return fonts.every(f => f.family);
case "fonts":
const f = getSectionValues("fonts");
return f.primary.name && f.primary.fallback && f.secondary.name && f.secondary.fallback;
case "favicon":
const fav = getSectionValues("favicon");
return !!fav.path;
default:
return true;
}
}
function isSectionSaved(section, loadedConfig) {
switch (section) {
case "colors":
const v = getSectionValues("colors");
const c = loadedConfig.colors || {};
return (
v.primary === c.primary &&
v.primary_dark === c.primary_dark &&
v.secondary === c.secondary &&
v.accent === c.accent &&
v.text_dark === c.text_dark &&
v.background === c.background &&
v.browser_color === c.browser_color
);
case "google-fonts":
const fonts = getSectionValues("google-fonts");
const cf = loadedConfig.google_fonts || [];
return JSON.stringify(fonts) === JSON.stringify(cf);
case "fonts":
const f = getSectionValues("fonts");
const cfnt = loadedConfig.fonts || {};
return (
f.primary.name === (cfnt.primary?.name || "") &&
f.primary.fallback === (cfnt.primary?.fallback || "") &&
f.secondary.name === (cfnt.secondary?.name || "") &&
f.secondary.fallback === (cfnt.secondary?.fallback || "")
);
case "favicon":
const fav = getSectionValues("favicon");
const cfav = loadedConfig.favicon || {};
return fav.path === (cfav.path || "");
default:
return true;
}
}
function updateSectionStatus(section, loadedConfig) {
const statusEl = document.querySelector(`#${section}-form .section-status`);
if (!statusEl) return;
if (!isSectionComplete(section)) {
statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
return;
}
if (isSectionSaved(section, loadedConfig)) {
statusEl.innerHTML = "";
statusEl.style.display = "none";
} else {
statusEl.innerHTML = "⚠️ Section not yet saved";
statusEl.style.color = "#ffc700";
statusEl.style.display = "";
statusEl.style.fontStyle = "normal";
}
}
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
const themeInfo = await fetchThemeInfo(); const themeInfo = await fetchThemeInfo();
const themeNameSpan = document.getElementById("current-theme"); const themeNameSpan = document.getElementById("current-theme");
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name; if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
const themeYaml = themeInfo.theme_yaml; let loadedConfig = themeInfo.theme_yaml;
const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : []; let googleFonts = loadedConfig.google_fonts ? JSON.parse(JSON.stringify(loadedConfig.google_fonts)) : [];
let localFonts = await fetchLocalFonts(themeInfo.theme_name); let localFonts = await fetchLocalFonts(themeInfo.theme_name);
// Colors // Colors
if (themeYaml.colors) { if (loadedConfig.colors) {
setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1"); setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", loadedConfig.colors.primary || "#0065a1");
setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384"); setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", loadedConfig.colors.primary_dark || "#005384");
setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0"); setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", loadedConfig.colors.secondary || "#00b0f0");
setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700"); setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", loadedConfig.colors.accent || "#ffc700");
setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161"); setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", loadedConfig.colors.text_dark || "#616161");
setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff"); setupColorPicker("color-background", "color-background-btn", "color-background-text", loadedConfig.colors.background || "#fff");
setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff"); setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", loadedConfig.colors.browser_color || "#fff");
} }
// Fonts // Fonts
function refreshFontDropdowns() { function refreshFontDropdowns() {
setFontDropdown("font-primary", document.getElementById("font-primary").value, [ setFontDropdown("font-primary", loadedConfig.fonts?.primary?.name || "Lato", [
...googleFonts.map(f => f.family), ...googleFonts.map(f => f.family),
...localFonts ...localFonts
]); ]);
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [ setFontDropdown("font-secondary", loadedConfig.fonts?.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family), ...googleFonts.map(f => f.family),
...localFonts ...localFonts
]); ]);
setFallbackDropdown("font-primary-fallback", loadedConfig.fonts?.primary?.fallback || "sans-serif");
setFallbackDropdown("font-secondary-fallback", loadedConfig.fonts?.secondary?.fallback || "serif");
} }
if (themeYaml.fonts) { refreshFontDropdowns();
setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
}
// Font upload logic // Font upload logic
const fontUploadInput = document.getElementById("font-upload"); const fontUploadInput = document.getElementById("font-upload");
const chooseFontBtn = document.getElementById("choose-font-btn"); const chooseFontBtn = document.getElementById("choose-font-btn");
const fontUploadStatus = document.getElementById("font-upload-status");
const localFontsList = document.getElementById("local-fonts-list"); const localFontsList = document.getElementById("local-fonts-list");
// Modal logic for font deletion // Modal logic for font deletion
@ -341,9 +462,9 @@ document.addEventListener("DOMContentLoaded", async () => {
}; };
} }
if (themeYaml.favicon && themeYaml.favicon.path) { if (loadedConfig.favicon && loadedConfig.favicon.path) {
faviconInput.value = themeYaml.favicon.path; faviconInput.value = loadedConfig.favicon.path;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`); updateFaviconPreview(`/themes/${themeInfo.theme_name}/${loadedConfig.favicon.path}?t=${Date.now()}`);
} else { } else {
updateFaviconPreview(""); updateFaviconPreview("");
} }
@ -367,6 +488,7 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts); googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts); renderGoogleFonts(googleFonts);
refreshFontDropdowns(); refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
}); });
} }
@ -396,6 +518,7 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts); googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts); renderGoogleFonts(googleFonts);
refreshFontDropdowns(); refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
} }
}, true); }, true);
@ -414,54 +537,72 @@ document.addEventListener("DOMContentLoaded", async () => {
googleFonts.push(...updatedGoogleFonts); googleFonts.push(...updatedGoogleFonts);
renderGoogleFonts(googleFonts); renderGoogleFonts(googleFonts);
refreshFontDropdowns(); refreshFontDropdowns();
updateSectionStatus("google-fonts", loadedConfig);
} }
}); });
} }
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => { // --- Section status listeners ---
e.preventDefault(); [
showLoader("Saving theme..."); { form: document.getElementById("colors-form"), section: "colors" },
const data = {}; { form: document.getElementById("google-fonts-form"), section: "google-fonts" },
data.colors = { { form: document.getElementById("fonts-form"), section: "fonts" },
primary: document.getElementById("color-primary-text").value, { form: document.getElementById("favicon-form"), section: "favicon" }
primary_dark: document.getElementById("color-primary-dark-text").value, ].forEach(({ form, section }) => {
secondary: document.getElementById("color-secondary-text").value, if (!form) return;
accent: document.getElementById("color-accent-text").value, form.addEventListener("input", () => updateSectionStatus(section, loadedConfig));
text_dark: document.getElementById("color-text-dark-text").value, form.addEventListener("change", () => updateSectionStatus(section, loadedConfig));
background: document.getElementById("color-background-text").value,
browser_color: document.getElementById("color-browser-color-text").value
};
data.fonts = {
primary: {
name: document.getElementById("font-primary").value,
fallback: document.getElementById("font-primary-fallback").value
},
secondary: {
name: document.getElementById("font-secondary").value,
fallback: document.getElementById("font-secondary-fallback").value
}
};
data.favicon = {
path: faviconInput.value
};
data.google_fonts = [];
document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => {
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value;
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
.split(",").map(w => w.trim()).filter(w => w);
if (family) data.google_fonts.push({ family, weights });
});
const res = await fetch("/api/theme-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data })
});
hideLoader();
if (res.ok) {
showToast("✅ Theme saved!", "success");
} else {
showToast("Error saving theme.", "error");
}
}); });
// --- 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();
if (!form.reportValidity() || !isSectionComplete(section)) {
showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section, loadedConfig);
return;
}
// 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: payload })
});
hideLoader();
if (res.ok) {
showToast("✅ Section saved!", "success");
const updatedThemeInfo = await fetchThemeInfo();
loadedConfig = updatedThemeInfo.theme_yaml;
updateSectionStatus(section, loadedConfig);
} else {
showToast("Error saving section.", "error");
}
});
});
// Initial status update
["colors", "google-fonts", "fonts", "favicon"].forEach(section => updateSectionStatus(section, loadedConfig));
}); });

View File

@ -11,54 +11,37 @@ function hideLoader() {
if (loader) loader.classList.remove("active"); if (loader) loader.classList.remove("active");
} }
// --- Upload gallery images --- // --- Generic upload handler ---
const galleryInput = document.getElementById('upload-gallery'); function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) {
if (galleryInput) { const input = document.getElementById(inputId);
galleryInput.addEventListener('change', async (e) => { if (!input) return;
input.addEventListener('change', async (e) => {
const files = e.target.files; const files = e.target.files;
if (!files.length) return; if (!files.length) return;
showLoader("Uploading photos..."); showLoader(loaderText);
const formData = new FormData(); const formData = new FormData();
for (const file of files) formData.append('files', file); for (const file of files) formData.append('files', file);
try { try {
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData }); const res = await fetch(apiUrl, { method: 'POST', body: formData });
const data = await res.json(); const data = await res.json();
hideLoader(); hideLoader();
if (res.ok) { if (res.ok) {
showToast(`${data.uploaded.length} gallery image(s) uploaded!`, "success"); showToast(`${data.uploaded.length} ${successMsg}`, "success");
if (typeof refreshGallery === "function") refreshGallery(); if (typeof refreshFn === "function") refreshFn();
} else showToast('Error: ' + data.error, "error"); } else showToast('Error: ' + data.error, "error");
} catch(err) { } catch(err) {
hideLoader(); hideLoader();
console.error(err); console.error(err);
showToast('Server error!', "error"); showToast('Server error!', "error");
} finally { e.target.value = ''; } } finally {
e.target.value = '';
}
}); });
} }
// --- Upload hero images --- // --- Setup all upload inputs ---
const heroInput = document.getElementById('upload-hero'); setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
if (heroInput) { setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
heroInput.addEventListener('change', async (e) => { setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
const files = e.target.files; setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
if (!files.length) return;
showLoader("Uploading hero photos...");
const formData = new FormData();
for (const file of files) formData.append('files', file);
try {
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
const data = await res.json();
hideLoader();
if (res.ok) {
showToast(`${data.uploaded.length} hero image(s) uploaded!`, "success");
if (typeof refreshHero === "function") refreshHero();
} else showToast('Error: ' + data.error, "error");
} catch(err) {
hideLoader();
console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; }
});
}

View File

@ -4,176 +4,204 @@
{% block content %} {% block content %}
<h1>Edit Site Info</h1> <h1>Edit Site Info</h1>
<form id="site-info-form">
<!-- Info Section --> <!-- Info Section -->
<fieldset> <form id="info-form" autocomplete="off">
<h2>Info</h2> <fieldset id="info-section">
<p>Set the basic information for your site and SEO</p> <h2>Info</h2>
<div class="fields"> <p class="section-status"></p>
<div class="input-field"> <p>Set the basic information for your site and SEO</p>
<label>Title</label> <div class="fields">
<input type="text" name="info.title" placeholder="Your site title" required> <div class="input-field">
</div> <label>Title</label>
<div class="input-field"> <input type="text" name="info.title" placeholder="Your site title" required>
<label>Subtitle</label>
<input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
</div>
<div class="input-field">
<label>Description</label>
<input type="text" name="info.description" placeholder="Your site description" required>
</div>
<div class="input-field">
<label>Canonical URL</label>
<input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
</div>
<div class="input-field">
<label>Keywords (comma separated)</label>
<input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
</div>
<div class="input-field">
<label>Author</label>
<input type="text" name="info.author" placeholder="Your Name" required>
</div>
</div>
</fieldset>
<!-- Social Section -->
<fieldset>
<h2>Social</h2>
<p>Set your social media links and thumbnail for link sharing</p>
<div class="fields">
<div class="input-field">
<label>Instagram URL</label>
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
<label class="thumbnail-form-label">Thumbnail</label>
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
<div class="thumbnail-form">
<input type="hidden" name="social.thumbnail" id="social-thumbnail">
<img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
<button type="button" id="remove-thumbnail-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
</div>
</div>
</div>
</fieldset>
<!-- Menu Section -->
<fieldset>
<h2>Menu</h2>
<p>Manage your site menu items. You can use tag combination to propose custom filters</p>
<div class="fields">
<div class="input-field" style="flex: 1 1 100%;">
<div id="menu-items-list"></div>
<button type="button" id="add-menu-item">+ Add menu item</button>
</div>
</div>
</fieldset>
<!-- Footer Section -->
<fieldset>
<h2>Footer</h2>
<p>Set your copyright informations and legal link name</p>
<div class="fields">
<div class="input-field">
<label>Copyright</label>
<input type="text" name="footer.copyright" required>
</div>
<div class="input-field">
<label>Legal Label</label>
<input type="text" name="footer.legal_label" re>
</div>
</div>
</fieldset>
<!-- Legals Section -->
<fieldset>
<h2>Legals</h2>
<p>Set your legal informations</p>
<div class="fields">
<div class="input-field">
<label>Hoster Name</label>
<input type="text" name="legals.hoster_name" placeholder="Name" required>
</div>
<div class="input-field">
<label>Hoster Address</label>
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
</div>
<div class="input-field">
<label>Hoster Contact</label>
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
</div>
<div class="input-field" style="flex: 1 1 100%;">
<label>Intellectual Property</label>
<div id="ip-list"></div>
<button type="button" id="add-ip-paragraph">+ Add paragraph</button>
</div>
</div>
</fieldset>
<!-- Build Section -->
<fieldset>
<h2>Build</h2>
<p>Select a theme from the dropdown menu or add your custom theme folder</p>
<div class="fields">
<div class="input-field">
<label>Theme</label>
<select name="build.theme" id="theme-select" required></select>
<button type="button" id="remove-theme-btn" class="remove-btn up-btn danger">🗑 Remove selected theme</button>
<input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;">
<button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
<label class="thumbnail-form-label">Images processing</label>
<p>If checked, images will be converted for web and resized to fit the theme</p>
<label>
<input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
Convert images
</label>
<label>
<input type="checkbox" name="build.resize_images" id="resize-images-checkbox">
Resize images
</label>
</div>
</div>
</fieldset>
<button type="submit">Save</button>
</form>
<div class="section">
<h2>Steps</h2>
<p> Follow the steps to generate your static gallery</p>
<ul id="stepper">
<li><a href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a class="step-active" href="/site-info">Configure site info</a></li>
<div></div>
<li><a href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div> </div>
</div> <div class="input-field">
<label>Subtitle</label>
<input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
</div>
<div class="input-field">
<label>Description</label>
<input type="text" name="info.description" placeholder="Your site description" required>
</div>
<div class="input-field">
<label>Canonical URL</label>
<input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
</div>
<div class="input-field">
<label>Keywords (comma separated)</label>
<input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
</div>
<div class="input-field">
<label>Author</label>
<input type="text" name="info.author" placeholder="Your Name" required>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="info">Save</button>
</fieldset>
</form>
<!-- Social Section -->
<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">
<label>Instagram URL</label>
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
<label class="thumbnail-form-label">Thumbnail</label>
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;">
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
<div class="thumbnail-form">
<input type="hidden" name="social.thumbnail" id="social-thumbnail" 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 -->
<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%;">
<div id="menu-items-list"></div>
<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 -->
<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">
<label>Copyright</label>
<input type="text" name="footer.copyright" required>
</div>
<div class="input-field">
<label>Legal Label</label>
<input type="text" name="footer.legal_label" required>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="footer">Save</button>
</fieldset>
</form>
<!-- Legals Section -->
<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">
<label>Hoster Name</label>
<input type="text" name="legals.hoster_name" placeholder="Name" required>
</div>
<div class="input-field">
<label>Hoster Address</label>
<input type="text" name="legals.hoster_address" placeholder="Street, Postal Code, City, Country" required>
</div>
<div class="input-field">
<label>Hoster Contact</label>
<input type="text" name="legals.hoster_contact" placeholder="Email or/and Phone" required>
</div>
<div class="input-field" style="flex: 1 1 100%;">
<label>Intellectual Property</label>
<div id="ip-list"></div>
<button type="button" id="add-ip-paragraph">+ Add paragraph</button>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="legals">Save</button>
</fieldset>
</form>
<!-- Build Section -->
<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"></select>
<button type="button" id="remove-theme-btn" class="remove-btn up-btn danger">🗑 Remove selected theme</button>
<input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;">
<button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
<label class="thumbnail-form-label">Images processing</label>
<p>If checked, images will be converted for web and resized to fit the theme</p>
<label>
<input class="thumbnail-form-label" type="checkbox" name="build.convert_images" id="convert-images-checkbox">
Convert images
</label>
<label>
<input type="checkbox" name="build.resize_images" id="resize-images-checkbox">
Resize images
</label>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="build">Save</button>
</fieldset>
</form>
<!-- Stepper -->
<div class="section">
<h2>Steps</h2>
<p>Follow the steps to generate your static gallery</p>
<ul id="stepper">
<li><a href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a class="step-active" href="/site-info">Configure site info</a></li>
<div></div>
<li><a href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
<!-- Delete thumbnail confirmation modal--> <!-- Delete thumbnail confirmation modal-->
<div class="content-inner"> <div class="content-inner">
<div id="delete-modal" class="modal" style="display:none;"> <div id="delete-modal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<span id="delete-modal-close" class="modal-close">&times;</span> <span id="delete-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3> <h3>Confirm Deletion</h3>
<p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p> <p id="delete-modal-text">Are you sure you want to remove this thumbnail?</p>
<div class="modal-actions"> <div class="modal-actions">
<button id="delete-modal-confirm" class="modal-btn danger">Remove</button> <button id="delete-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-modal-cancel" class="modal-btn">Cancel</button> <button id="delete-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Delete theme confirmation modal --> </div>
<div class="content-inner"> <!-- Delete theme confirmation modal -->
<div id="delete-theme-modal" class="modal" style="display:none;"> <div class="content-inner">
<div class="modal-content"> <div id="delete-theme-modal" class="modal" style="display:none;">
<span id="delete-theme-modal-close" class="modal-close">&times;</span> <div class="modal-content">
<h3>Confirm Theme Deletion</h3> <span id="delete-theme-modal-close" class="modal-close">&times;</span>
<p id="delete-theme-modal-text">Are you sure you want to remove this theme?</p> <h3>Confirm Theme Deletion</h3>
<div class="modal-actions"> <p id="delete-theme-modal-text">Are you sure you want to remove this theme?</p>
<button id="delete-theme-modal-confirm" class="modal-btn danger">Remove</button> <div class="modal-actions">
<button id="delete-theme-modal-cancel" class="modal-btn">Cancel</button> <button id="delete-theme-modal-confirm" class="modal-btn danger">Remove</button>
</div> <button id="delete-theme-modal-cancel" class="modal-btn">Cancel</button>
</div> </div>
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}

View File

@ -7,7 +7,7 @@ body {
flex-direction: column; flex-direction: column;
min-height: 100vh; min-height: 100vh;
margin:0px; margin:0px;
width: 100vw; min-width: 320px;
} }
a { a {
@ -236,11 +236,32 @@ h2 {
cursor: pointer; cursor: pointer;
} }
/* --- Filter Row --- */
.filter-row label {
display: inline-block;
margin-right: 16px;
font-weight: bold;
cursor: pointer;
}
.filter-row input[type="radio"] {
accent-color: #55c3ec;
margin-right: 6px;
}
/* --- Gallery & Hero Grid --- */ /* --- Gallery & Hero Grid --- */
#gallery, #hero { #gallery, #hero {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 15px; gap: 15px;
margin: 30px 0 0 0;
}
#hero-count-bottom, #gallery-count-bottom {
flex-basis: 100%;
}
.bottom-action-row {
margin-top: 30px; margin-top: 30px;
} }
@ -316,6 +337,20 @@ h2 {
.toast.success { background-color: #28a7468c; } .toast.success { background-color: #28a7468c; }
.toast.error { background-color: #dc3545; } .toast.error { background-color: #dc3545; }
/* img fade in */
.fade-in-img {
opacity: 0;
transform: scale(1.02);
transition: opacity 1.2s ease-out, transform 1.2s ease-out;
will-change: opacity, transform;
}
.fade-in-img.loaded {
opacity: 1;
transform: scale(1);
}
/* --- Tags --- */ /* --- Tags --- */
.tag-input { .tag-input {
display: flex; display: flex;
@ -366,15 +401,18 @@ h2 {
z-index: 999; z-index: 999;
display: none; display: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.15); box-shadow: 0 4px 8px rgba(0,0,0,0.15);
padding: 8px;
border-radius: 10px;
} }
.tag-input ul.suggestions li { .tag-input ul.suggestions li {
padding: 6px 8px; padding: 6px 10px;
cursor: pointer; cursor: pointer;
} }
.tag-input ul.suggestions li:hover { .tag-input ul.suggestions li:hover {
background-color: #007782; background-color: #007782;
border-radius: 7px;
} }
.tags-display { .tags-display {
@ -406,7 +444,9 @@ h2 {
background-color: #007782; background-color: #007782;
color: white; color: white;
cursor: pointer; cursor: pointer;
border-radius: 7px;
} }
.suggestions li { .suggestions li {
cursor: pointer; cursor: pointer;
} }
@ -416,6 +456,14 @@ h2 {
display: flex; display: flex;
} }
.modal-actions-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically */
}
.flex-column { .flex-column {
flex-direction: column; flex-direction: column;
} }
@ -521,18 +569,23 @@ h2 {
align-items: center; align-items: center;
gap: 16px; gap: 16px;
margin-bottom: 10px; margin-bottom: 10px;
flex-wrap: wrap;
} }
/* --- Remove All Buttons --- */ /* --- Remove All Buttons --- */
#remove-all-hero, #remove-all-gallery { #remove-all-hero, #remove-all-gallery, .bottom-remove-btn {
background: #2d2d2d; background: #2d2d2d;
color: white; color: white;
display: none; display: none;
margin-bottom: 6px; margin-bottom: 6px;
} }
.bottom-remove-btn {
display: inherit;
}
#remove-all-gallery:hover, #remove-all-gallery:hover,
#remove-all-hero:hover { #remove-all-hero:hover, .bottom-remove-btn:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
@ -541,6 +594,7 @@ h2 {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
} }
@ -588,9 +642,9 @@ label {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
#site-info-form input, #theme-editor-form input, form input,
#site-info-form textarea, #theme-editor-form textarea, form textarea,
#site-info-form select, #theme-editor-form select { form select {
background: #1f2223; background: #1f2223;
color: #fff; color: #fff;
border: 1px solid #585858; border: 1px solid #585858;
@ -612,27 +666,24 @@ label {
gap: 0 18px; gap: 0 18px;
} }
.theme-info {
margin-bottom: 25px;
}
form input::placeholder,
#site-info-form input::placeholder, form textarea::placeholder {
#theme-editor-form input::placeholder,
#site-info-form textarea::placeholder,
#theme-editor-form textarea::placeholder {
color: #585858; color: #585858;
font-style: italic; font-style: italic;
} }
#site-info-form input:focus, form input:focus,
#theme-editor-form input:focus, form textarea:focus,
#site-info-form textarea:focus, form select:focus {
#theme-editor-form textarea:focus,
#site-info-form select:focus,
#theme-editor-form select:focus {
border-color: #585858; border-color: #585858;
background: #161616; background: #161616;
} }
#site-info-form textarea,
#theme-editor-form textarea { form textarea {
min-height: 60px; min-height: 60px;
resize: vertical; resize: vertical;
} }
@ -651,7 +702,7 @@ img#thumbnail-preview {
border: 1px solid #585858; border: 1px solid #585858;
} }
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] { form button[type="submit"] {
background: linear-gradient(135deg, #26c4ff, #016074); background: linear-gradient(135deg, #26c4ff, #016074);
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
@ -659,17 +710,18 @@ img#thumbnail-preview {
border-radius: 30px; border-radius: 30px;
padding: 12px 32px; padding: 12px 32px;
font-size: 1.1em; font-size: 1.1em;
margin: 0 0 45px 0; margin: 16px 0 0 0;
cursor: pointer; cursor: pointer;
box-shadow: 0 4px 16px rgba(38,196,255,0.15); box-shadow: 0 4px 16px rgba(38,196,255,0.15);
transition: background 0.2s; transition: background 0.2s;
display: block;
} }
#site-info-form button[type="submit"]:hover, #theme-editor-form button[type="submit"]:hover { form button[type="submit"]:hover {
background: linear-gradient(135deg, #72d9ff, #26657e); background: linear-gradient(135deg, #72d9ff, #26657e);
} }
#site-info-form button[type="button"], #theme-editor-form button[type="button"] { form button[type="button"] {
background: #00000000; background: #00000000;
color: #fff; color: #fff;
border: none; border: none;
@ -682,34 +734,36 @@ img#thumbnail-preview {
border: 1px solid #585858; border: 1px solid #585858;
} }
#site-info-form button[type="button"]:hover, #theme-editor-form button[type="button"]:hover { form button[type="button"]:hover{
background: #2d2d2d; background: #2d2d2d;
color: #fff; color: #fff;
} }
#site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph, #theme-editor-form button.remove-menu-item, #theme-editor-form button.remove-ip-paragraph { form button.remove-menu-item,
form button.remove-ip-paragraph {
margin-top: 0px; margin-top: 0px;
margin-bottom: 4px; margin-bottom: 4px;
border-radius: 30px; border-radius: 30px;
background: #2d2d2d; background: #2d2d2d;
} }
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover { form button.remove-menu-item:hover,
form button.remove-ip-paragraph:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
#site-info-form button.remove-btn, #theme-editor-form button.remove-btn { form button.remove-btn {
border-radius: 30px; border-radius: 30px;
background: #2d2d2d; background: #2d2d2d;
} }
#site-info-form button.remove-btn:hover, #theme-editor-form button.remove-btn:hover { form button.remove-btn:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
#site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label { form .thumbnail-form-label {
margin-top: 10px; margin-top: 10px;
} }
@ -771,6 +825,10 @@ fieldset p, .section p {
margin-top: 16px; margin-top: 16px;
} }
.section-status {
font-style: normal;
}
/* --- Stepper --- */ /* --- Stepper --- */
#stepper { #stepper {
@ -905,6 +963,7 @@ justify-content: center;
#footer a { #footer a {
color: #fff; color: #fff;
margin: auto;
} }
.footer-credit .lum-first::before { .footer-credit .lum-first::before {
@ -920,6 +979,7 @@ justify-content: center;
width: 16px; width: 16px;
height: 16px; height: 16px;
display: flex; display: flex;
margin: auto;
} }
.icon-text { .icon-text {
@ -994,12 +1054,12 @@ justify-content: center;
flex-direction: column; flex-direction: column;
} }
#stepper { form button[type="button"], form button[type="submit"], #stepper li {
flex-direction: column; width: 100%;
} }
#stepper li { #stepper {
width: 100%; flex-direction: column;
} }
.footer-container, .footer-links { .footer-container, .footer-links {
@ -1020,14 +1080,16 @@ justify-content: center;
/* Hide the default arrow */ /* Hide the default arrow */
color: transparent; color: transparent;
} }
#site-info-form, #theme-editor-form {
padding: 18px 8px;
}
.input-field { .input-field {
min-width: 100%; min-width: 100%;
margin-bottom: 12px; margin-bottom: 12px;
} }
#site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
width: 100%;
}
#color-picker .input-field{ #color-picker .input-field{
min-width: 170px; min-width: 170px;
} }

View File

@ -48,7 +48,10 @@
<span id="build-success-modal-close" class="modal-close">&times;</span> <span id="build-success-modal-close" class="modal-close">&times;</span>
<h3>✅ Build completed!</h3> <h3>✅ Build completed!</h3>
<p>Your files are available in the output folder.</p> <p>Your files are available in the output folder.</p>
<button id="download-zip-btn" class="modal-btn">Download ZIP</button> <div class="modal-actions-row">
<button id="preview-site-btn" class="modal-btn" data-preview-port="{{ preview_port }}">🔎 Preview Site</button>
<button id="download-zip-btn" class="modal-btn">📦 Download ZIP</button>
</div>
<div id="zip-loader" style="display:none;">Creating ZIP...</div> <div id="zip-loader" style="display:none;">Creating ZIP...</div>
</div> </div>
</div> </div>
@ -65,7 +68,7 @@
</div> </div>
<div class="footer-links"> <div class="footer-links">
<a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a> <a class="footer-link documentation" href="https://lumeex.djeex.fr"><span class="icon"><img src="/img/favicon.svg"></span><span class="icon-text">Documentation</span></a>
<a class="footer-link gitea" href="https://gitea.com/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a> <a class="footer-link gitea" href="https://git.djeex.fr/Djeex/lumeex"><span class="icon"><img src="/img/gitea.svg"></span><span class="icon-text">Giteex</span></a>
<a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a> <a class="footer-link github" href="https://github.com/Djeex/lumeex"><span class="icon"><img src="/img/github.svg"></span><span class="icon-text">Github</span></a>
</div> </div>
</div> </div>

View File

@ -4,176 +4,193 @@
{% block content %} {% block content %}
<h1>Edit Theme</h1> <h1>Edit Theme</h1>
<!-- Show current theme --> <!-- Show current theme -->
<div class="theme-info"> <div class="theme-info">
<strong>Current theme:</strong> <span id="current-theme"></span> <strong>Current theme:</strong> <span id="current-theme"></span>
</div> </div>
<form id="theme-editor-form"> <div id="theme-editor-form">
<!-- Colors Section --> <!-- Colors Section -->
<fieldset id="color-picker"> <form id="colors-form" autocomplete="off">
<h2>Colors</h2> <fieldset id="color-picker">
<p>Set the color values for your theme</p> <h2>Colors</h2>
<div class="fields"> <p class="section-status"></p>
<!-- Example for one color field, repeat for all --> <p>Set the color values for your theme</p>
<div class="input-field"> <div class="fields">
<label>Primary</label> <div class="input-field">
<div class="fields color-fields"> <label>Primary</label>
<button type="button" id="color-primary-btn" class="color-btn"></button> <div class="fields color-fields">
<input type="color" name="colors.primary" id="color-primary" class="color-input"> <button type="button" id="color-primary-btn" class="color-btn"></button>
<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>
</div> <input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;" required>
</div>
<div class="input-field">
<label>Primary Dark</label>
<div class="fields color-fields">
<button type="button" id="color-primary-dark-btn" class="color-btn"></button>
<input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input">
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Secondary</label>
<div class="fields color-fields">
<button type="button" id="color-secondary-btn" class="color-btn"></button>
<input type="color" name="colors.secondary" id="color-secondary" class="color-input">
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Accent</label>
<div class="fields color-fields">
<button type="button" id="color-accent-btn" class="color-btn"></button>
<input type="color" name="colors.accent" id="color-accent" class="color-input">
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Text Dark</label>
<div class="fields color-fields">
<button type="button" id="color-text-dark-btn" class="color-btn"></button>
<input type="color" name="colors.text_dark" id="color-text-dark" class="color-input">
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Background</label>
<div class="fields color-fields">
<button type="button" id="color-background-btn" class="color-btn"></button>
<input type="color" name="colors.background" id="color-background" class="color-input">
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
</div>
</div>
<div class="input-field">
<label>Browser Color</label>
<div class="fields color-fields">
<button type="button" id="color-browser-color-btn" class="color-btn"></button>
<input type="color" name="colors.browser_color" id="color-browser-color" class="color-input">
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
</div>
</div> </div>
</div> </div>
</fieldset> <div class="input-field">
<!-- Google Fonts Section --> <label>Primary Dark</label>
<fieldset> <div class="fields color-fields">
<h2>Google Fonts</h2> <button type="button" id="color-primary-dark-btn" class="color-btn"></button>
<p>Add Google Fonts to your theme</p> <input type="color" name="colors.primary_dark" id="color-primary-dark" class="color-input" required>
<div class="fields" id="google-fonts-fields"> <input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;" required>
<!-- JS will render font family and weights inputs here -->
</div>
<button type="button" id="add-google-font">Add Google Font</button>
</fieldset>
<!-- Custom Font Upload Section -->
<fieldset>
<h2>Upload Custom Font</h2>
<p>Supported formats: .woff, .woff2</p>
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
<div id="local-fonts-list" class="font-list"></div>
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
</fieldset>
<!-- Fonts Section -->
<fieldset>
<h2>Fonts</h2>
<p>Select where to apply your fonts</p>
<div class="fields">
<div class="input-field">
<label>Primary Font</label>
<select name="fonts.primary.name" id="font-primary"></select>
<label>Fallback</label>
<select name="fonts.primary.fallback" id="font-primary-fallback">
<option value="sans-serif">sans-serif</option>
<option value="serif">serif</option>
</select>
</div>
<div class="input-field">
<label>Secondary Font</label>
<select name="fonts.secondary.name" id="font-secondary"></select>
<label>Fallback</label>
<select name="fonts.secondary.fallback" id="font-secondary-fallback">
<option value="sans-serif">sans-serif</option>
<option value="serif">serif</option>
</select>
</div> </div>
</div> </div>
</fieldset> <div class="input-field">
<!-- Favicon Section --> <label>Secondary</label>
<fieldset> <div class="fields color-fields">
<h2>Favicon</h2> <button type="button" id="color-secondary-btn" class="color-btn"></button>
<p>Supported formats: .png, .jpg, .jpeg</p> <input type="color" name="colors.secondary" id="color-secondary" class="color-input" required>
<div class="fields"> <input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;" required>
<div class="input-field"> </div>
<label>Favicon Path</label> </div>
<input type="text" name="favicon.path" id="favicon-path" readonly> <div class="input-field">
<input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;"> <label>Accent</label>
<button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button> <div class="fields color-fields">
<div class="favicon-form"> <button type="button" id="color-accent-btn" class="color-btn"></button>
<img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;"> <input type="color" name="colors.accent" id="color-accent" class="color-input" required>
<button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button> <input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;" required>
</div> </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" 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" 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" required>
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;" required>
</div> </div>
</div> </div>
</fieldset>
<button type="submit">Save Theme</button>
</form>
<div class="section">
<h2>Steps</h2>
<p> Follow the steps to generate your static gallery</p>
<ul id="stepper">
<li><a href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a href="/site-info">Configure site info</a></li>
<div></div>
<li><a class="step-active" href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
</div>
</div>
<!-- Delete confirmation modal for favicon -->
<div id="delete-favicon-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-favicon-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
<div class="modal-actions">
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
</div> </div>
</div> <button type="submit" class="section-save-btn" data-section="colors">Save</button>
</div> </fieldset>
<!-- Delete confirmation modal for font --> </form>
<div id="delete-font-modal" class="modal" style="display:none;"> <!-- Google Fonts Section -->
<div class="modal-content"> <form id="google-fonts-form" autocomplete="off">
<span id="delete-font-modal-close" class="modal-close">&times;</span> <fieldset>
<h3>Confirm Deletion</h3> <h2>Google Fonts</h2>
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p> <p class="section-status"></p>
<div class="modal-actions"> <p>Add Google Fonts to your theme</p>
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button> <div class="fields" id="google-fonts-fields">
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button> <!-- JS will render font family and weights inputs here -->
</div> </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" required></select>
<label>Fallback</label>
<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" required></select>
<label>Fallback</label>
<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">
<label>Favicon Path</label>
<input type="text" name="favicon.path" id="favicon-path" readonly>
<input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;">
<button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button>
<div class="favicon-form">
<img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;">
<button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
</div>
</div>
</div>
<button type="submit" class="section-save-btn" data-section="favicon">Save</button>
</fieldset>
</form>
</div>
<!-- Stepper -->
<div class="section">
<h2>Steps</h2>
<p>Follow the steps to generate your static gallery</p>
<ul id="stepper">
<li><a href="/gallery-editor">Upload your photos</a></li>
<div></div>
<li><a href="/site-info">Configure site info</a></li>
<div></div>
<li><a class="step-active" href="/theme-editor">Customize your theme</a></li>
<div></div>
<li><button id="stepper-build">Generate your static site!</button></li>
</ul>
</div>
<!-- Delete confirmation modal for favicon -->
<div id="delete-favicon-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-favicon-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
<div class="modal-actions">
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
</div> </div>
</div> </div>
</div>
<!-- Delete confirmation modal for font -->
<div id="delete-font-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-font-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
<div class="modal-actions">
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
{% endblock %} {% endblock %}