diff --git a/.gitignore b/.gitignore index 7adc878..a833ea8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .* +!.env !.sh !.gitignore output/ diff --git a/VERSION b/VERSION index 10bf840..50aea0e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.1 \ No newline at end of file +2.1.0 \ No newline at end of file diff --git a/config/site.yaml b/config/site.yaml index 166b129..2545965 100644 --- a/config/site.yaml +++ b/config/site.yaml @@ -1,4 +1,3 @@ -# Please change this by your settings. info: title: subtitle: @@ -9,8 +8,8 @@ info: social: instagram_url: - thumbnail: - + thumbnail: '' + menu: items: - label: Home @@ -18,19 +17,17 @@ menu: footer: copyright: Copyright © 2025 - legal_link: '/legals/' + legal_link: /legals/ legal_label: Legal notice -# Build parameters build: - theme: modern # choose a theme in config/theme folder - convert_images: true # true to enable image conversion - resize_images: true # true to enable image resizing - -# Change this by your legals + theme: modern + convert_images: true + resize_images: true + legals: hoster_name: - hoster_adress: + hoster_address: hoster_contact: intellectual_property: - - paragraph: "" + - paragraph: '' diff --git a/config/themes/modern/theme.css b/config/themes/modern/theme.css index 84c8a73..d6c5671 100644 --- a/config/themes/modern/theme.css +++ b/config/themes/modern/theme.css @@ -35,16 +35,13 @@ img, tag { #footer { box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5); - margin: auto; } @media (max-width: 768px) { #footer { box-shadow: 0px 20px 100px -44px rgba(0, 0, 0, 0.5); - border-radius: 15px 15px 0 0; max-width: 1140px; - margin: 0; } .tag { @@ -53,7 +50,7 @@ img, tag { .hero-background { max-width: 90%; - margin: auto; + margin: 0 auto; } .back-button { diff --git a/config/themes/modern/theme.yaml b/config/themes/modern/theme.yaml index 26cfa8a..db895de 100644 --- a/config/themes/modern/theme.yaml +++ b/config/themes/modern/theme.yaml @@ -1,32 +1,31 @@ -#-----------------------------------# -# Modern theme for Lumeex # -# https://git.djeex.fr/Djeex/lumeex # -#-----------------------------------# colors: - primary: '#0065a1' + primary: '#0065A1' primary_dark: '#005384' - secondary: '#00b0f0' - accent: '#ffc700' + secondary: '#00B0F0' + accent: '#FFC700' text_dark: '#616161' - background: '#ffffff' - browser_color: '#ffffff' + background: '#FFFFFF' + browser_color: '#FFFFFF' + favicon: path: favicon.png -google_fonts: - - family: Lato - weights: - - '200' - - '400' - - '700' - - family: Montserrat - weights: - - '200' - - '400' - - '700' + fonts: primary: - name: Lato fallback: sans-serif + name: Lato secondary: + fallback: serif name: Montserrat - fallback: serif \ No newline at end of file + +google_fonts: +- family: 'Lato' + weights: + - '200' + - '400' + - '700' +- family: Montserrat + weights: + - '200' + - '400' + - '700' diff --git a/config/themes/typewriter/theme.yaml b/config/themes/typewriter/theme.yaml index 546e89d..66ea5a6 100644 --- a/config/themes/typewriter/theme.yaml +++ b/config/themes/typewriter/theme.yaml @@ -1,21 +1,17 @@ -#-----------------------------------# -# Typewriter theme for Lumeex # -# https://git.djeex.fr/Djeex/lumeex # -#-----------------------------------# colors: - primary: '#0065a1' + accent: '#FFC700' + background: '#FFFFFF' + browser_color: '#FFFFFF' + primary: '#0065A1' primary_dark: '#005384' - secondary: '#00b0f0' - accent: '#ffc700' + secondary: '#00B0F0' text_dark: '#616161' - background: '#ffffff' - browser_color: '#ffffff' favicon: path: favicon.png fonts: primary: - name: Trixie + name: trixie fallback: sans-serif secondary: - name: Trixie - fallback: serif \ No newline at end of file + name: trixie + fallback: serif diff --git a/demo/config/themes/modern/theme.yaml b/demo/config/themes/modern/theme.yaml index 3f331f5..9142466 100644 --- a/demo/config/themes/modern/theme.yaml +++ b/demo/config/themes/modern/theme.yaml @@ -1,32 +1,31 @@ -#-----------------------------------# -# Modern theme for Lumeex # -# https://git.djeex.fr/Djeex/lumeex # -#-----------------------------------# colors: - primary: '#0065a1' + primary: '#0065A1' primary_dark: '#005384' - secondary: '#00b0f0' - accent: '#ffc700' + secondary: '#00B0F0' + accent: '#FFC700' text_dark: '#616161' - background: '#fff' - browser_color: '#fff' + background: '#FFFFFF' + browser_color: '#FFFFFF' + favicon: path: favicon.png -google_fonts: - - family: Lato - weights: - - '200' - - '400' - - '700' - - family: Montserrat - weights: - - '200' - - '400' - - '700' + fonts: primary: - name: Lato fallback: sans-serif + name: Lato secondary: + fallback: serif name: Montserrat - fallback: serif \ No newline at end of file + +google_fonts: +- family: '' + weights: + - '200' + - '400' + - '700' +- family: Montserrat + weights: + - '200' + - '400' + - '700' diff --git a/demo/config/themes/typewriter/theme.yaml b/demo/config/themes/typewriter/theme.yaml index 7a30379..32fde01 100644 --- a/demo/config/themes/typewriter/theme.yaml +++ b/demo/config/themes/typewriter/theme.yaml @@ -1,21 +1,19 @@ -#-----------------------------------# -# Typewriter theme for Lumeex # -# https://git.djeex.fr/Djeex/lumeex # -#-----------------------------------# colors: - primary: '#0065a1' + accent: '#FFC700' + background: '#FFFFFF' + browser_color: '#FFFFFF' + primary: '#0065A1' primary_dark: '#005384' - secondary: '#00b0f0' - accent: '#ffc700' + secondary: '#00B0F0' text_dark: '#616161' - background: '#fff' - browser_color: '#fff' + favicon: path: favicon.png + fonts: primary: - name: Trixie + name: trixie.woff fallback: sans-serif secondary: - name: Trixie - fallback: serif \ No newline at end of file + name: trixie.woff + fallback: serif diff --git a/docker/.env b/docker/.env new file mode 100644 index 0000000..bcc894c --- /dev/null +++ b/docker/.env @@ -0,0 +1,2 @@ +PREVIEW_PORT=3000 +WEBUI_PORT=5000 \ No newline at end of file diff --git a/docker/.sh/entrypoint.sh b/docker/.sh/entrypoint.sh index 77d9eb2..18d9da4 100644 --- a/docker/.sh/entrypoint.sh +++ b/docker/.sh/entrypoint.sh @@ -5,29 +5,33 @@ CYAN="\033[1;36m" NC="\033[0m" copy_default_config() { - echo "Checking configuration directory..." + echo "[~] Checking configuration directory..." if [ ! -d "/app/config" ]; then mkdir -p /app/config fi - echo "Checking if default config files need to be copied..." + echo "[~] Checking if default config files/folders need to be copied..." files_copied=false - for file in /app/default/*; do - filename=$(basename "$file") - target="/app/config/$filename" - + # Recursively check all files and folders in /app/default + while IFS= read -r src; do + relpath="${src#/app/default/}" + target="/app/config/$relpath" if [ ! -e "$target" ]; then - echo "Copying default config file: $filename" - cp -r "$file" "$target" + echo "[→] Copying: $relpath" + if [ -d "$src" ]; then + cp -r "$src" "$target" + else + cp "$src" "$target" + fi files_copied=true fi - done + done < <(find /app/default -mindepth 1) if [ "$files_copied" = true ]; then - echo "Default configuration files copied successfully." + echo "[✓] Default configuration files/folders copied successfully." else - echo "No default files needed to be copied." + echo "[✓] No default files/folders needed to be copied." fi } @@ -43,11 +47,13 @@ start_server() { cat /tmp/build_logs_fifo >&2 & cat /tmp/build_logs_fifo2 >&2 & - echo "Starting preview HTTP server on port 3000..." + PREVIEW_PORT="${PREVIEW_PORT:-3000}" + echo "[~]Starting preview HTTP server on port 3000..." + echo "[i] Preview host port is set to: ${PREVIEW_PORT}" python3 -u -m http.server 3000 -d /app/output & SERVER_PID=$! - echo "Starting Lumeex Flask webui..." + echo "[~] Starting Lumeex Flask webui..." python3 -u -m src.py.webui.webui & WEBUI_PID=$! @@ -72,15 +78,15 @@ fi case "$1" in build) - echo "Running build.py..." + echo "[~] Running build.py..." python3 -u /app/build.py 2>&1 | tee /tmp/build_logs_fifo ;; gallery) - echo "Running gallery.py..." + echo "[~] Running gallery.py..." python3 -u /app/gallery.py 2>&1 | tee /tmp/build_logs_fifo2 ;; *) - echo "Unknown command: $1" + echo "[!] Unknown command: $1" exec "$@" ;; esac diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index f48000c..793d5af 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -2,10 +2,15 @@ services: lumeex: container_name: lmx build: .. + env_file: + - .env + environment: + - PREVIEW_PORT=${PREVIEW_PORT:-3000} # port for preview server - set it in .env file + - WEBUI_PORT=${WEBUI_PORT:-5000} # port for webui server - set it in .env file volumes: - ../config:/app/config # mount config directory - ../output:/app/output # mount output directory ports: - - "3000:3000" - - "5000:5000" + - "${PREVIEW_PORT:-3000}:3000" + - "${WEBUI_PORT:-5000}:5000" \ No newline at end of file diff --git a/src/public/js/lazy.js b/src/public/js/lazy.js index 0dc1c3d..e31518e 100644 --- a/src/public/js/lazy.js +++ b/src/public/js/lazy.js @@ -9,7 +9,6 @@ window.addEventListener("DOMContentLoaded", () => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; - console.log("Lazy-loading image:", img.dataset.src); img.src = img.dataset.src; img.onload = () => { img.classList.add("loaded"); diff --git a/src/public/style/style.css b/src/public/style/style.css index ce22718..5cf76de 100644 --- a/src/public/style/style.css +++ b/src/public/style/style.css @@ -78,6 +78,8 @@ html,body { font-weight: 400; line-height:1.5; color:var(--color-primary-dark); + display: flex; + flex-direction: column; } html { @@ -179,8 +181,8 @@ h2 { /* animation */ .appear { - -webkit-transition: all 0.3s; - transition: all 0.3s; + -webkit-transition: all 1s; + transition: all 1s; opacity: 0; -webkit-transform: translateY(20px); transform: translateY(20px); @@ -237,12 +239,23 @@ h2 { /* Hero */ #hero { - height: 100%; - width: 100%; + min-height: 100vh; + flex: 1 0 auto; + display: flex; + flex-direction: column; } + +#hero .section { + height: 100%; + display: flex; + flex-direction: column; + width: 100%; +} + #hero .content-wrapper, #hero .section { height:100%; } + .hero-background { height: 66%; width: 100%; @@ -303,7 +316,6 @@ h2 { font-size: 22px; } - .gallery { padding-top: 15px; } @@ -341,6 +353,11 @@ h2 { /* Footer */ +#footer { + margin: auto 0 0 0; + width: 100%; +} + .navigation { text-align: center; padding-top: 40px; @@ -470,8 +487,7 @@ h2 { #legals.content-wrapper { max-width: 90%; - margin: auto; - margin-top: 50px; + margin: 50px auto; } .legals-content { diff --git a/src/py/webui/webui.py b/src/py/webui/webui.py index 75969ac..98dc758 100644 --- a/src/py/webui/webui.py +++ b/src/py/webui/webui.py @@ -30,6 +30,8 @@ app = Flask( static_url_path="" ) +WEBUI_PORT = int(os.getenv("WEBUI_PORT", 5000)) + # --- Config paths --- SITE_YAML = Path(__file__).resolve().parents[3] / "config" / "site.yaml" PHOTOS_DIR = Path(__file__).resolve().parents[3] / "config" / "photos" @@ -72,9 +74,14 @@ def get_local_fonts(theme_name): def index(): return render_template("index.html") +PREVIEW_PORT = int(os.getenv("PREVIEW_PORT", 3000)) + @app.context_processor def inject_version(): - return dict(lumeex_version=lumeex_version) + return dict( + lumeex_version=lumeex_version, + preview_port=PREVIEW_PORT + ) # --- Gallery & Hero API --- @app.route("/gallery-editor") @@ -203,9 +210,21 @@ def get_site_info(): @app.route("/api/site-info", methods=["POST"]) def update_site_info(): """Update site info YAML.""" - data = request.json + new_data = request.json + with open(SITE_YAML, "r") as f: + old_data = yaml.safe_load(f) or {} + + def deep_merge(old, new): + for k, v in new.items(): + if isinstance(v, dict) and isinstance(old.get(k), dict): + old[k] = deep_merge(old[k], v) + else: + old[k] = v + return old + + merged = deep_merge(old_data, new_data) with open(SITE_YAML, "w") as f: - yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) + yaml.safe_dump(merged, f, sort_keys=False, allow_unicode=True) return jsonify({"status": "ok"}) # --- Theme management --- @@ -396,7 +415,8 @@ def upload_font(): fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts" fonts_dir.mkdir(parents=True, exist_ok=True) file.save(fonts_dir / file.filename) - return jsonify({"status": "ok", "filename": file.filename}) + font_basename = Path(file.filename).stem + return jsonify({"status": "ok", "filename": font_basename}) @app.route("/api/font/remove", methods=["POST"]) def remove_font(): @@ -487,5 +507,6 @@ def download_output_zip(): # --- Run server --- if __name__ == "__main__": - logging.info("Starting WebUI at http://0.0.0.0:5000") + logging.info("[~] Starting WebUI at http://0.0.0.0:5000") + logging.info(f"[i] WebUI host port is set to {WEBUI_PORT}") app.run(host="0.0.0.0", port=5000, debug=True) \ No newline at end of file diff --git a/src/webui/gallery-editor/index.html b/src/webui/gallery-editor/index.html index 329a65c..3960ad7 100644 --- a/src/webui/gallery-editor/index.html +++ b/src/webui/gallery-editor/index.html @@ -18,7 +18,16 @@ +
+ +
+ + + +
@@ -31,8 +40,27 @@ +
+ + +
+ + +
diff --git a/src/webui/js/build.js b/src/webui/js/build.js index 91022da..3c7790b 100644 --- a/src/webui/js/build.js +++ b/src/webui/js/build.js @@ -92,6 +92,18 @@ document.addEventListener("DOMContentLoaded", () => { }); } + // Preview Site button + const previewBtn = document.getElementById("preview-site-btn"); + if (previewBtn) { + const previewPort = previewBtn.getAttribute("data-preview-port") || "3000"; + previewBtn.onclick = () => { + const host = window.location.hostname; + const protocol = window.location.protocol; + const url = `${protocol}//${host}:${previewPort}/`; + window.open(url, "_blank"); + }; + } + // Modal close logic if (buildModal && buildModalClose) { buildModalClose.onclick = () => { diff --git a/src/webui/js/gallery-editor.js b/src/webui/js/gallery-editor.js index 640e567..e7ebcd6 100644 --- a/src/webui/js/gallery-editor.js +++ b/src/webui/js/gallery-editor.js @@ -1,18 +1,29 @@ -// --- Arrays to store gallery and hero images --- let galleryImages = []; let heroImages = []; -let allTags = []; // global tag list +let allTags = []; +let showOnlyUntagged = false; + +// --- Fade-in helper --- +function applyFadeInImages(container) { + container.querySelectorAll("img.fade-in-img").forEach(img => { + const onLoad = () => img.classList.add("loaded"); + if (img.complete && img.naturalHeight !== 0) onLoad(); + else { + img.addEventListener("load", onLoad, { once: true }); + img.addEventListener("error", () => { + console.warn("Image failed to load:", img.dataset?.src || img.src); + }); + } + }); +} // --- Load images from server on page load --- async function loadData() { try { - const galleryRes = await fetch('/api/gallery'); - galleryImages = await galleryRes.json(); + galleryImages = await (await fetch('/api/gallery')).json(); updateAllTags(); renderGallery(); - - const heroRes = await fetch('/api/hero'); - heroImages = await heroRes.json(); + heroImages = await (await fetch('/api/hero')).json(); renderHero(); } catch(err) { console.error(err); @@ -24,22 +35,43 @@ async function loadData() { function updateAllTags() { allTags = []; galleryImages.forEach(img => { - if (img.tags) img.tags.forEach(t => { + (img.tags || []).forEach(t => { if (!allTags.includes(t)) allTags.push(t); }); }); } +// --- Helper: update count and button visibility --- +function updateCountAndButtons(prefix, count) { + const countTop = document.getElementById(`${prefix}-count`); + const countBottom = document.getElementById(`${prefix}-count-bottom`); + if (countTop) countTop.innerHTML = `

${count} photos

`; + if (countBottom) countBottom.innerHTML = `

${count} photos

`; + + const removeAllBtn = document.getElementById(`remove-all-${prefix}`); + const removeAllBtnBottom = document.getElementById(`remove-all-${prefix}-bottom`); + if (removeAllBtn) removeAllBtn.style.display = count > 0 ? 'inline-block' : 'none'; + if (removeAllBtnBottom) removeAllBtnBottom.style.display = count > 0 ? 'inline-block' : 'none'; + + const bottomUpload = document.getElementById(`bottom-${prefix}-upload`); + if (bottomUpload) bottomUpload.style.display = count > 0 ? 'flex' : 'none'; +} + // --- Render gallery images with tags and delete buttons --- function renderGallery() { const container = document.getElementById('gallery'); container.innerHTML = ''; - galleryImages.forEach((img, i) => { + let imagesToShow = showOnlyUntagged + ? galleryImages.filter(img => !img.tags || img.tags.length === 0) + : galleryImages; + + imagesToShow.forEach((img) => { + const i = galleryImages.indexOf(img); const div = document.createElement('div'); div.className = 'photo flex-item flex-column'; div.innerHTML = `
- +
@@ -50,22 +82,17 @@ function renderGallery() {
`; container.appendChild(div); - renderTags(i, img.tags || []); }); - // Show/hide Remove All button - const removeAllBtn = document.getElementById('remove-all-gallery'); - if (removeAllBtn) { - removeAllBtn.style.display = galleryImages.length > 0 ? 'inline-block' : 'none'; - } + updateCountAndButtons('gallery', imagesToShow.length); + applyFadeInImages(container); } // --- Render tags for a single image --- function renderTags(imgIndex, tags) { const tagsDisplay = document.querySelector(`.tags-display[data-index="${imgIndex}"]`); const inputContainer = document.querySelector(`.tag-input[data-index="${imgIndex}"]`); - tagsDisplay.innerHTML = ''; inputContainer.innerHTML = ''; @@ -73,7 +100,6 @@ function renderTags(imgIndex, tags) { const span = document.createElement('span'); span.className = 'tag'; span.textContent = tag; - const remove = document.createElement('span'); remove.className = 'remove-tag'; remove.textContent = '×'; @@ -82,7 +108,6 @@ function renderTags(imgIndex, tags) { updateTags(imgIndex, tags); renderTags(imgIndex, tags); }; - span.appendChild(remove); tagsDisplay.appendChild(span); }); @@ -92,11 +117,10 @@ function renderTags(imgIndex, tags) { input.placeholder = 'Add tag...'; inputContainer.appendChild(input); - // --- Validate button --- const validateBtn = document.createElement('button'); validateBtn.textContent = '✔️'; validateBtn.className = 'validate-tag-btn'; - validateBtn.style.display = 'none'; // hidden by default + validateBtn.style.display = 'none'; validateBtn.style.marginLeft = '4px'; inputContainer.appendChild(validateBtn); @@ -116,30 +140,20 @@ function renderTags(imgIndex, tags) { const updateSuggestions = () => { const value = input.value.toLowerCase(); - const allTagsFlat = galleryImages.flatMap(img => img.tags || []); const tagCount = {}; allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1); - - const allTagsSorted = Object.keys(tagCount) - .sort((a, b) => tagCount[b] - tagCount[a]); - + const allTagsSorted = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]); const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t)); - suggestionBox.innerHTML = ''; selectedIndex = -1; - if (suggestions.length) { suggestionBox.style.display = 'block'; suggestions.forEach((s, idx) => { const li = document.createElement('li'); li.style.fontStyle = 'italic'; li.style.textAlign = 'left'; - - const boldPart = `${s.substring(0, input.value.length)}`; - const rest = s.substring(input.value.length); - li.innerHTML = boldPart + rest; - + li.innerHTML = `${s.substring(0, input.value.length)}${s.substring(input.value.length)}`; li.addEventListener('mousedown', (e) => { e.preventDefault(); addTag(s); @@ -147,7 +161,6 @@ function renderTags(imgIndex, tags) { input.focus(); updateSuggestions(); }); - li.onmouseover = () => selectedIndex = idx; suggestionBox.appendChild(li); }); @@ -164,7 +177,6 @@ function renderTags(imgIndex, tags) { updateSuggestions(); validateBtn.style.display = input.value.trim() ? 'inline-block' : 'none'; }); - input.addEventListener('keydown', (e) => { const items = suggestionBox.querySelectorAll('li'); if (e.key === 'ArrowDown') { @@ -195,27 +207,22 @@ function renderTags(imgIndex, tags) { validateBtn.style.display = 'none'; } }); - input.addEventListener('blur', () => { - setTimeout(() => { - suggestionBox.style.display = 'none'; - input.value = ''; - validateBtn.style.display = 'none'; - }, 150); + suggestionBox.style.display = 'none'; + input.value = ''; + validateBtn.style.display = 'none'; }); - - // --- Validate button action --- - validateBtn.onclick = () => { + validateBtn.addEventListener('mousedown', (e) => { + e.preventDefault(); if (input.value.trim()) { addTag(input.value.trim()); input.value = ''; updateSuggestions(); validateBtn.style.display = 'none'; } - }; - - input.focus(); + }); updateSuggestions(); + if (!input.value.trim()) suggestionBox.style.display = 'none'; } // --- Update tags in galleryImages array --- @@ -233,7 +240,7 @@ function renderHero() { div.className = 'photo flex-item flex-column'; div.innerHTML = `
- +
@@ -243,12 +250,8 @@ function renderHero() { `; container.appendChild(div); }); - - // Show/hide Remove All button - const removeAllBtn = document.getElementById('remove-all-hero'); - if (removeAllBtn) { - removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none'; - } + updateCountAndButtons('hero', heroImages.length); + applyFadeInImages(container); } // --- Save gallery to server --- @@ -294,33 +297,27 @@ async function refreshHero() { function showToast(message, type = "success", duration = 3000) { const container = document.getElementById("toast-container"); if (!container) return; - const toast = document.createElement("div"); toast.className = `toast ${type}`; toast.textContent = message; container.appendChild(toast); - requestAnimationFrame(() => toast.classList.add("show")); - setTimeout(() => { toast.classList.remove("show"); toast.addEventListener("transitionend", () => toast.remove()); }, duration); } -let pendingDelete = null; // { type: 'gallery'|'hero'|'gallery-all'|'hero-all', index: number|null } +let pendingDelete = null; // --- Show delete confirmation modal --- function showDeleteModal(type, index = null) { pendingDelete = { type, index }; const modalText = document.getElementById('delete-modal-text'); - if (type === 'gallery-all') { - modalText.textContent = "Are you sure you want to delete ALL gallery images?"; - } else if (type === 'hero-all') { - modalText.textContent = "Are you sure you want to delete ALL hero images?"; - } else { - modalText.textContent = "Are you sure you want to delete this image?"; - } + modalText.textContent = + type === 'gallery-all' ? "Are you sure you want to delete ALL gallery images?" + : type === 'hero-all' ? "Are you sure you want to delete ALL hero images?" + : "Are you sure you want to delete this image?"; document.getElementById('delete-modal').style.display = 'flex'; } @@ -333,15 +330,10 @@ function hideDeleteModal() { // --- Confirm deletion --- async function confirmDelete() { if (!pendingDelete) return; - if (pendingDelete.type === 'gallery') { - await actuallyDeleteGalleryImage(pendingDelete.index); - } else if (pendingDelete.type === 'hero') { - await actuallyDeleteHeroImage(pendingDelete.index); - } else if (pendingDelete.type === 'gallery-all') { - await actuallyDeleteAllGalleryImages(); - } else if (pendingDelete.type === 'hero-all') { - await actuallyDeleteAllHeroImages(); - } + if (pendingDelete.type === 'gallery') await actuallyDeleteGalleryImage(pendingDelete.index); + else if (pendingDelete.type === 'hero') await actuallyDeleteHeroImage(pendingDelete.index); + else if (pendingDelete.type === 'gallery-all') await actuallyDeleteAllGalleryImages(); + else if (pendingDelete.type === 'hero-all') await actuallyDeleteAllHeroImages(); hideDeleteModal(); } @@ -423,15 +415,35 @@ async function actuallyDeleteAllHeroImages() { // --- Modal event listeners and bulk delete buttons --- document.addEventListener('DOMContentLoaded', () => { - document.getElementById('delete-modal-close').onclick = hideDeleteModal; - document.getElementById('delete-modal-cancel').onclick = hideDeleteModal; - document.getElementById('delete-modal-confirm').onclick = confirmDelete; + ['delete-modal-close', 'delete-modal-cancel'].forEach(id => { + const el = document.getElementById(id); + if (el) el.onclick = hideDeleteModal; + }); + const confirmBtn = document.getElementById('delete-modal-confirm'); + if (confirmBtn) confirmBtn.onclick = confirmDelete; + + // Gallery filter radios + const showAllRadio = document.getElementById('show-all-radio'); + const showUntaggedRadio = document.getElementById('show-untagged-radio'); + if (showAllRadio) showAllRadio.addEventListener('change', () => { + showOnlyUntagged = false; + renderGallery(); + }); + if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => { + showOnlyUntagged = true; + renderGallery(); + }); // Bulk delete buttons - const removeAllGalleryBtn = document.getElementById('remove-all-gallery'); - const removeAllHeroBtn = document.getElementById('remove-all-hero'); - if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all'); - if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all'); + [ + ['remove-all-gallery', 'gallery-all'], + ['remove-all-gallery-bottom', 'gallery-all'], + ['remove-all-hero', 'hero-all'], + ['remove-all-hero-bottom', 'hero-all'] + ].forEach(([btnId, type]) => { + const btn = document.getElementById(btnId); + if (btn) btn.onclick = () => showDeleteModal(type); + }); }); // --- Initialize --- diff --git a/src/webui/js/site-info.js b/src/webui/js/site-info.js index 3af7338..ec58b1e 100644 --- a/src/webui/js/site-info.js +++ b/src/webui/js/site-info.js @@ -12,7 +12,6 @@ function showToast(message, type = "success", duration = 3000) { }, duration); } -// --- Loader helpers --- function showLoader(text = "Uploading...") { const loader = document.getElementById("global-loader"); if (loader) { @@ -26,119 +25,99 @@ function hideLoader() { } document.addEventListener("DOMContentLoaded", () => { - // Form and menu logic - const form = document.getElementById("site-info-form"); + // --- Section Forms --- + const forms = { + info: document.getElementById("info-form"), + social: document.getElementById("social-form"), + menu: document.getElementById("menu-form"), + footer: document.getElementById("footer-form"), + legals: document.getElementById("legals-form"), + build: document.getElementById("build-form") + }; + + // --- Menu logic --- const menuList = document.getElementById("menu-items-list"); const addMenuBtn = document.getElementById("add-menu-item"); - let menuItems = []; - - // Render menu items function renderMenuItems() { menuList.innerHTML = ""; menuItems.forEach((item, idx) => { - const div = document.createElement("div"); - div.style.display = "flex"; - div.style.gap = "8px"; - div.style.marginBottom = "6px"; - div.innerHTML = ` - - - + menuList.innerHTML += ` +
+ + + +
`; - menuList.appendChild(div); }); } - - // Update menu items from inputs function updateMenuItemsFromInputs() { const inputs = menuList.querySelectorAll("input"); - const items = []; + menuItems = []; for (let i = 0; i < inputs.length; i += 2) { const label = inputs[i].value.trim(); const href = inputs[i + 1].value.trim(); - if (label || href) items.push({ label, href }); + if (label || href) menuItems.push({ label, href }); } - menuItems = items; } - // Intellectual property paragraphs logic + // --- Intellectual property paragraphs logic --- const ipList = document.getElementById("ip-list"); const addIpBtn = document.getElementById("add-ip-paragraph"); let ipParagraphs = []; - - // Render IP paragraphs function renderIpParagraphs() { ipList.innerHTML = ""; ipParagraphs.forEach((item, idx) => { - const div = document.createElement("div"); - div.style.display = "flex"; - div.style.gap = "8px"; - div.style.marginBottom = "6px"; - div.innerHTML = ` - - + ipList.innerHTML += ` +
+ + +
`; - ipList.appendChild(div); }); } - - // Update IP paragraphs from textareas function updateIpParagraphsFromInputs() { - const textareas = ipList.querySelectorAll("textarea"); - ipParagraphs = Array.from(textareas).map(textarea => ({ - paragraph: textarea.value.trim() - })).filter(item => item.paragraph !== ""); + ipParagraphs = Array.from(ipList.querySelectorAll("textarea")) + .map(textarea => ({ paragraph: textarea.value.trim() })) + .filter(item => item.paragraph !== ""); } - // Build options + // --- Build options & Theme select --- const convertImagesCheckbox = document.getElementById("convert-images-checkbox"); const resizeImagesCheckbox = document.getElementById("resize-images-checkbox"); - - // Theme select const themeSelect = document.getElementById("theme-select"); - // Thumbnail upload and modal logic - const thumbnailInput = form?.elements["social.thumbnail"]; + // --- Thumbnail upload and modal logic --- + const thumbnailInput = document.getElementById("social-thumbnail"); const thumbnailUpload = document.getElementById("thumbnail-upload"); const chooseThumbnailBtn = document.getElementById("choose-thumbnail-btn"); const thumbnailPreview = document.getElementById("thumbnail-preview"); const removeThumbnailBtn = document.getElementById("remove-thumbnail-btn"); - // Modal elements for delete confirmation - const deleteModal = document.getElementById("delete-modal"); - const deleteModalClose = document.getElementById("delete-modal-close"); - const deleteModalConfirm = document.getElementById("delete-modal-confirm"); - const deleteModalCancel = document.getElementById("delete-modal-cancel"); + // --- Modal helpers --- + function setupModal(modal, closeBtn, confirmBtn, cancelBtn, onConfirm) { + if (!modal) return; + if (closeBtn) closeBtn.onclick = () => modal.style.display = "none"; + if (cancelBtn) cancelBtn.onclick = () => modal.style.display = "none"; + window.addEventListener("click", (e) => { + if (e.target === modal) modal.style.display = "none"; + }); + if (confirmBtn && onConfirm) confirmBtn.onclick = onConfirm; + } - // Modal elements for theme deletion - const deleteThemeModal = document.getElementById("delete-theme-modal"); - const deleteThemeModalClose = document.getElementById("delete-theme-modal-close"); - const deleteThemeModalConfirm = document.getElementById("delete-theme-modal-confirm"); - const deleteThemeModalCancel = document.getElementById("delete-theme-modal-cancel"); - const deleteThemeModalText = document.getElementById("delete-theme-modal-text"); - let themeToDelete = null; - - // Show/hide thumbnail preview, remove button, and choose button + // --- Thumbnail preview logic --- function updateThumbnailPreview(src) { if (thumbnailPreview) { thumbnailPreview.src = src || ""; thumbnailPreview.style.display = src ? "block" : "none"; } - if (removeThumbnailBtn) { - removeThumbnailBtn.style.display = src ? "inline-block" : "none"; - } - if (chooseThumbnailBtn) { - chooseThumbnailBtn.style.display = src ? "none" : "inline-block"; - } + if (removeThumbnailBtn) removeThumbnailBtn.style.display = src ? "inline-block" : "none"; + if (chooseThumbnailBtn) chooseThumbnailBtn.style.display = src ? "none" : "inline-block"; } - // Choose thumbnail button triggers file input if (chooseThumbnailBtn && thumbnailUpload) { chooseThumbnailBtn.addEventListener("click", () => thumbnailUpload.click()); } - - // Handle thumbnail upload and refresh preview (with cache busting) if (thumbnailUpload) { thumbnailUpload.addEventListener("change", async (e) => { const file = e.target.files[0]; @@ -156,27 +135,20 @@ document.addEventListener("DOMContentLoaded", () => { } else { showToast("❌ Error uploading thumbnail", "error"); } + updateSectionStatus("social"); }); } - - // Remove thumbnail button triggers modal if (removeThumbnailBtn) { removeThumbnailBtn.addEventListener("click", () => { - deleteModal.style.display = "flex"; + document.getElementById("delete-modal").style.display = "flex"; }); } - - // Modal logic for thumbnail deletion - if (deleteModal && deleteModalClose && deleteModalConfirm && deleteModalCancel) { - deleteModalClose.onclick = deleteModalCancel.onclick = () => { - deleteModal.style.display = "none"; - }; - window.onclick = function(event) { - if (event.target === deleteModal) { - deleteModal.style.display = "none"; - } - }; - deleteModalConfirm.onclick = async () => { + setupModal( + document.getElementById("delete-modal"), + document.getElementById("delete-modal-close"), + document.getElementById("delete-modal-confirm"), + document.getElementById("delete-modal-cancel"), + async () => { const res = await fetch("/api/thumbnail/remove", { method: "POST" }); const result = await res.json(); if (result.status === "ok") { @@ -186,48 +158,38 @@ document.addEventListener("DOMContentLoaded", () => { } else { showToast("❌ Error removing thumbnail", "error"); } - deleteModal.style.display = "none"; - }; - } + document.getElementById("delete-modal").style.display = "none"; + updateSectionStatus("social"); + } + ); - // Theme upload logic (custom theme folder) + // --- Theme upload logic --- const themeUpload = document.getElementById("theme-upload"); const chooseThemeBtn = document.getElementById("choose-theme-btn"); if (chooseThemeBtn && themeUpload) { chooseThemeBtn.addEventListener("click", () => themeUpload.click()); themeUpload.addEventListener("change", async (e) => { const files = Array.from(e.target.files); - if (files.length === 0) return; + if (!files.length) return; showLoader("Uploading theme..."); const formData = new FormData(); - files.forEach(file => { - formData.append("files", file, file.webkitRelativePath || file.name); - }); + files.forEach(file => formData.append("files", file, file.webkitRelativePath || file.name)); const res = await fetch("/api/theme/upload", { method: "POST", body: formData }); const result = await res.json(); hideLoader(); if (result.status === "ok") { showToast("✅ Theme uploaded!", "success"); - // Refresh theme select after upload - fetch("/api/themes") - .then(res => res.json()) - .then(themes => { - themeSelect.innerHTML = ""; - themes.forEach(theme => { - const option = document.createElement("option"); - option.value = theme; - option.textContent = theme; - themeSelect.appendChild(option); - }); - }); + refreshThemes(); } else { showToast("❌ Error uploading theme", "error"); } + updateSectionStatus("build"); }); } - // Remove theme button triggers modal + // --- Remove theme logic --- const removeThemeBtn = document.getElementById("remove-theme-btn"); + let themeToDelete = null; if (removeThemeBtn && themeSelect) { removeThemeBtn.addEventListener("click", () => { const theme = themeSelect.value; @@ -237,24 +199,16 @@ document.addEventListener("DOMContentLoaded", () => { return; } themeToDelete = theme; - deleteThemeModalText.textContent = `Are you sure you want to remove theme "${theme}"?`; - deleteThemeModal.style.display = "flex"; + document.getElementById("delete-theme-modal-text").textContent = `Are you sure you want to remove theme "${theme}"?`; + document.getElementById("delete-theme-modal").style.display = "flex"; }); } - - // Modal logic for theme deletion - if (deleteThemeModal && deleteThemeModalClose && deleteThemeModalConfirm && deleteThemeModalCancel) { - deleteThemeModalClose.onclick = deleteThemeModalCancel.onclick = () => { - deleteThemeModal.style.display = "none"; - themeToDelete = null; - }; - window.onclick = function(event) { - if (event.target === deleteThemeModal) { - deleteThemeModal.style.display = "none"; - themeToDelete = null; - } - }; - deleteThemeModalConfirm.onclick = async () => { + setupModal( + document.getElementById("delete-theme-modal"), + document.getElementById("delete-theme-modal-close"), + document.getElementById("delete-theme-modal-confirm"), + document.getElementById("delete-theme-modal-cancel"), + async () => { if (!themeToDelete) return; showLoader("Removing theme..."); const res = await fetch("/api/theme/remove", { @@ -266,28 +220,18 @@ document.addEventListener("DOMContentLoaded", () => { hideLoader(); if (result.status === "ok") { showToast("✅ Theme removed!", "success"); - // Refresh theme select - fetch("/api/themes") - .then(res => res.json()) - .then(themes => { - themeSelect.innerHTML = ""; - themes.forEach(theme => { - const option = document.createElement("option"); - option.value = theme; - option.textContent = theme; - themeSelect.appendChild(option); - }); - }); + refreshThemes(); } else { showToast(result.error || "❌ Error removing theme", "error"); } - deleteThemeModal.style.display = "none"; + document.getElementById("delete-theme-modal").style.display = "none"; themeToDelete = null; - }; - } + updateSectionStatus("build"); + } + ); - // Fetch theme list and populate select - if (themeSelect) { + // --- Theme select refresh --- + function refreshThemes() { fetch("/api/themes") .then(res => res.json()) .then(themes => { @@ -298,148 +242,276 @@ document.addEventListener("DOMContentLoaded", () => { option.textContent = theme; themeSelect.appendChild(option); }); - // Set selected value after loading config - fetch("/api/site-info") - .then(res => res.json()) - .then(data => { - themeSelect.value = data.build?.theme || ""; - }); + loadConfigAndUpdateBuildStatus(); }); } - // Load config from server and populate form - if (form) { + // --- Config loading --- + let loadedConfig = {}; + function loadConfigAndUpdateBuildStatus() { fetch("/api/site-info") .then(res => res.json()) .then(data => { + loadedConfig = data; + // Info + if (forms.info) { + forms.info.elements["info.title"].value = data.info?.title || ""; + forms.info.elements["info.subtitle"].value = data.info?.subtitle || ""; + forms.info.elements["info.description"].value = data.info?.description || ""; + forms.info.elements["info.canonical"].value = data.info?.canonical || ""; + forms.info.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || ""); + forms.info.elements["info.author"].value = data.info?.author || ""; + } + // Social + if (forms.social) { + forms.social.elements["social.instagram_url"].value = data.social?.instagram_url || ""; + if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || ""; + updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : ""); + } + // Menu + menuItems = Array.isArray(data.menu?.items) ? data.menu.items : []; + renderMenuItems(); + // Footer + if (forms.footer) { + forms.footer.elements["footer.copyright"].value = data.footer?.copyright || ""; + forms.footer.elements["footer.legal_label"].value = data.footer?.legal_label || ""; + } + // Legals ipParagraphs = Array.isArray(data.legals?.intellectual_property) ? data.legals.intellectual_property : []; renderIpParagraphs(); - menuItems = Array.isArray(data.menu?.items) ? data.menu.items : []; - renderMenuItems(); - form.elements["info.title"].value = data.info?.title || ""; - form.elements["info.subtitle"].value = data.info?.subtitle || ""; - form.elements["info.description"].value = data.info?.description || ""; - form.elements["info.canonical"].value = data.info?.canonical || ""; - form.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || ""); - form.elements["info.author"].value = data.info?.author || ""; - form.elements["social.instagram_url"].value = data.social?.instagram_url || ""; - if (thumbnailInput) thumbnailInput.value = data.social?.thumbnail || ""; - updateThumbnailPreview(data.social?.thumbnail ? `/photos/${data.social.thumbnail}?t=${Date.now()}` : ""); - form.elements["footer.copyright"].value = data.footer?.copyright || ""; - form.elements["footer.legal_label"].value = data.footer?.legal_label || ""; - if (themeSelect) { - themeSelect.value = data.build?.theme || ""; - } - form.elements["legals.hoster_name"].value = data.legals?.hoster_name || ""; - form.elements["legals.hoster_address"].value = data.legals?.hoster_address || ""; - form.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || ""; - // Build checkboxes - if (convertImagesCheckbox) { - convertImagesCheckbox.checked = !!data.build?.convert_images; - } - if (resizeImagesCheckbox) { - resizeImagesCheckbox.checked = !!data.build?.resize_images; + if (forms.legals) { + forms.legals.elements["legals.hoster_name"].value = data.legals?.hoster_name || ""; + forms.legals.elements["legals.hoster_address"].value = data.legals?.hoster_address || ""; + forms.legals.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || ""; } + // Build + if (themeSelect) themeSelect.value = data.build?.theme || ""; + if (convertImagesCheckbox) convertImagesCheckbox.checked = !!data.build?.convert_images; + if (resizeImagesCheckbox) resizeImagesCheckbox.checked = !!data.build?.resize_images; + ["info", "social", "menu", "footer", "legals"].forEach(updateSectionStatus); + updateSectionStatus("build"); }); } + if (themeSelect) refreshThemes(); + else loadConfigAndUpdateBuildStatus(); - // Add menu item - if (addMenuBtn) { - addMenuBtn.addEventListener("click", () => { - menuItems.push({ label: "", href: "" }); - renderMenuItems(); - }); - } - - // Remove menu item + // --- Add/remove menu items --- + if (addMenuBtn) addMenuBtn.addEventListener("click", () => { + menuItems.push({ label: "", href: "" }); + renderMenuItems(); + updateSectionStatus("menu"); + }); menuList.addEventListener("click", (e) => { if (e.target.classList.contains("remove-menu-item")) { const idx = parseInt(e.target.getAttribute("data-idx")); menuItems.splice(idx, 1); renderMenuItems(); + updateSectionStatus("menu"); } }); - - // Update menuItems on input change menuList.addEventListener("input", () => { updateMenuItemsFromInputs(); + updateSectionStatus("menu"); }); - // Add paragraph - if (addIpBtn) { - addIpBtn.addEventListener("click", () => { - ipParagraphs.push({ paragraph: "" }); - renderIpParagraphs(); - }); - } - - // Remove paragraph + // --- Add/remove IP paragraphs --- + if (addIpBtn) addIpBtn.addEventListener("click", () => { + ipParagraphs.push({ paragraph: "" }); + renderIpParagraphs(); + updateSectionStatus("legals"); + }); ipList.addEventListener("click", (e) => { if (e.target.classList.contains("remove-ip-paragraph")) { const idx = parseInt(e.target.getAttribute("data-idx")); ipParagraphs.splice(idx, 1); renderIpParagraphs(); + updateSectionStatus("legals"); } }); - - // Update ipParagraphs on input change ipList.addEventListener("input", () => { updateIpParagraphsFromInputs(); + updateSectionStatus("legals"); }); - // Save config to server - if (form) { + // --- Section value helpers --- + function getSectionValues(section) { + switch (section) { + case "info": + return { + title: forms.info.elements["info.title"].value, + subtitle: forms.info.elements["info.subtitle"].value, + description: forms.info.elements["info.description"].value, + canonical: forms.info.elements["info.canonical"].value, + keywords: forms.info.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean), + author: forms.info.elements["info.author"].value + }; + case "social": + return { + instagram_url: forms.social.elements["social.instagram_url"].value, + thumbnail: thumbnailInput ? thumbnailInput.value : "" + }; + case "menu": + updateMenuItemsFromInputs(); + return { items: menuItems }; + case "footer": + return { + copyright: forms.footer.elements["footer.copyright"].value, + legal_label: forms.footer.elements["footer.legal_label"].value + }; + case "legals": + updateIpParagraphsFromInputs(); + 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) => { e.preventDefault(); - updateMenuItemsFromInputs(); - updateIpParagraphsFromInputs(); - - // Check if thumbnail is set before saving (uploaded or present in input) - if (!thumbnailInput || !thumbnailInput.value) { - showLoader("Saving..."); - showToast("❌ Thumbnail is required.", "error"); - hideLoader(); + if (!form.reportValidity()) { + showToast("❌ Please fill all required fields before saving.", "error"); + updateSectionStatus(section); return; } - - const build = { - theme: themeSelect ? themeSelect.value : "", - convert_images: !!(convertImagesCheckbox && convertImagesCheckbox.checked), - resize_images: !!(resizeImagesCheckbox && resizeImagesCheckbox.checked) - }; - - const payload = { - info: { - title: form.elements["info.title"].value, - subtitle: form.elements["info.subtitle"].value, - description: form.elements["info.description"].value, - canonical: form.elements["info.canonical"].value, - keywords: form.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean), - author: form.elements["info.author"].value - }, - social: { - instagram_url: form.elements["social.instagram_url"].value, - thumbnail: thumbnailInput ? thumbnailInput.value : "" - }, - menu: { - items: menuItems - }, - footer: { - copyright: form.elements["footer.copyright"].value, - legal_label: form.elements["footer.legal_label"].value - }, - build, - legals: { - hoster_name: form.elements["legals.hoster_name"].value, - hoster_address: form.elements["legals.hoster_address"].value, - hoster_contact: form.elements["legals.hoster_contact"].value, - intellectual_property: ipParagraphs + if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) { + showToast("❌ Thumbnail is required.", "error"); + updateSectionStatus(section); + return; + } + if (section === "menu") { + updateMenuItemsFromInputs(); + if (!menuItems.length || !menuItems.every(item => item.label && item.href)) { + showToast("❌ Please fill all menu item fields.", "error"); + updateSectionStatus(section); + return; } - }; - // --- REMOVE loader for save --- - // showLoader("Saving..."); + } + if (section === "legals") { + updateIpParagraphsFromInputs(); + if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) { + showToast("❌ Please fill all intellectual property paragraphs.", "error"); + updateSectionStatus(section); + return; + } + } + let payload = {}; + payload[section] = getSectionValues(section); const res = await fetch("/api/site-info", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -447,10 +519,16 @@ document.addEventListener("DOMContentLoaded", () => { }); const result = await res.json(); if (result.status === "ok") { - showToast("✅ Site info saved!", "success"); + showToast("✅ Section saved!", "success"); + fetch("/api/site-info") + .then(res => res.json()) + .then(data => { + loadedConfig = data; + updateSectionStatus(section); + }); } else { - showToast("❌ Error saving site info", "error"); + showToast("❌ Error saving section", "error"); } }); - } + }); }); \ No newline at end of file diff --git a/src/webui/js/theme-editor.js b/src/webui/js/theme-editor.js index f7b45c4..481c51d 100644 --- a/src/webui/js/theme-editor.js +++ b/src/webui/js/theme-editor.js @@ -70,9 +70,11 @@ function setupColorPicker(colorId, btnId, textId, initial) { function setFontDropdown(selectId, value, options) { const select = document.getElementById(selectId); if (!select) return; - select.innerHTML = options.map(opt => - `` - ).join(""); + select.innerHTML = options.map(opt => { + // Remove extension if present + const base = opt.replace(/\.(woff2?|ttf|otf)$/, ""); + return ``; + }).join(""); } function setFallbackDropdown(selectId, value) { @@ -116,54 +118,173 @@ function renderLocalFonts(fonts) { }); } +// --- Section helpers --- +function getSectionValues(section) { + switch (section) { + case "colors": + return { + primary: document.getElementById("color-primary-text").value, + primary_dark: document.getElementById("color-primary-dark-text").value, + secondary: document.getElementById("color-secondary-text").value, + accent: document.getElementById("color-accent-text").value, + text_dark: document.getElementById("color-text-dark-text").value, + background: document.getElementById("color-background-text").value, + browser_color: document.getElementById("color-browser-color-text").value + }; + case "google-fonts": + const googleFontsFields = document.getElementById("google-fonts-fields"); + const fonts = []; + if (googleFontsFields) { + googleFontsFields.querySelectorAll(".input-field").forEach(field => { + const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value.trim(); + const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value + .split(",").map(w => w.trim()).filter(Boolean); + if (family) fonts.push({ family, weights }); + }); + } + return fonts; + case "fonts": + return { + primary: { + name: document.getElementById("font-primary").value, + fallback: document.getElementById("font-primary-fallback").value + }, + secondary: { + name: document.getElementById("font-secondary").value, + fallback: document.getElementById("font-secondary-fallback").value + } + }; + case "favicon": + return { + path: document.getElementById("favicon-path").value + }; + default: + return {}; + } +} + +function isSectionComplete(section) { + switch (section) { + case "colors": + const v = getSectionValues("colors"); + return ( + v.primary && + v.primary_dark && + v.secondary && + v.accent && + v.text_dark && + v.background && + v.browser_color + ); + case "google-fonts": + const fonts = getSectionValues("google-fonts"); + return fonts.every(f => f.family); + case "fonts": + const f = getSectionValues("fonts"); + return f.primary.name && f.primary.fallback && f.secondary.name && f.secondary.fallback; + case "favicon": + const fav = getSectionValues("favicon"); + return !!fav.path; + default: + return true; + } +} + +function isSectionSaved(section, loadedConfig) { + switch (section) { + case "colors": + const v = getSectionValues("colors"); + const c = loadedConfig.colors || {}; + return ( + v.primary === c.primary && + v.primary_dark === c.primary_dark && + v.secondary === c.secondary && + v.accent === c.accent && + v.text_dark === c.text_dark && + v.background === c.background && + v.browser_color === c.browser_color + ); + case "google-fonts": + const fonts = getSectionValues("google-fonts"); + const cf = loadedConfig.google_fonts || []; + return JSON.stringify(fonts) === JSON.stringify(cf); + case "fonts": + const f = getSectionValues("fonts"); + const cfnt = loadedConfig.fonts || {}; + return ( + f.primary.name === (cfnt.primary?.name || "") && + f.primary.fallback === (cfnt.primary?.fallback || "") && + f.secondary.name === (cfnt.secondary?.name || "") && + f.secondary.fallback === (cfnt.secondary?.fallback || "") + ); + case "favicon": + const fav = getSectionValues("favicon"); + const cfav = loadedConfig.favicon || {}; + return fav.path === (cfav.path || ""); + default: + return true; + } +} + +function updateSectionStatus(section, loadedConfig) { + const statusEl = document.querySelector(`#${section}-form .section-status`); + if (!statusEl) return; + if (!isSectionComplete(section)) { + statusEl.innerHTML = "⚠️ Section not yet saved. Please fill required fields"; + statusEl.style.color = "#ffc700"; + statusEl.style.display = ""; + statusEl.style.fontStyle = "normal"; + return; + } + if (isSectionSaved(section, loadedConfig)) { + statusEl.innerHTML = ""; + statusEl.style.display = "none"; + } else { + statusEl.innerHTML = "⚠️ Section not yet saved"; + statusEl.style.color = "#ffc700"; + statusEl.style.display = ""; + statusEl.style.fontStyle = "normal"; + } +} + document.addEventListener("DOMContentLoaded", async () => { const themeInfo = await fetchThemeInfo(); const themeNameSpan = document.getElementById("current-theme"); if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name; - const themeYaml = themeInfo.theme_yaml; - const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : []; + let loadedConfig = themeInfo.theme_yaml; + let googleFonts = loadedConfig.google_fonts ? JSON.parse(JSON.stringify(loadedConfig.google_fonts)) : []; let localFonts = await fetchLocalFonts(themeInfo.theme_name); // Colors - if (themeYaml.colors) { - setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", themeYaml.colors.primary || "#0065a1"); - setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384"); - setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0"); - setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", themeYaml.colors.accent || "#ffc700"); - setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", themeYaml.colors.text_dark || "#616161"); - setupColorPicker("color-background", "color-background-btn", "color-background-text", themeYaml.colors.background || "#fff"); - setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", themeYaml.colors.browser_color || "#fff"); + if (loadedConfig.colors) { + setupColorPicker("color-primary", "color-primary-btn", "color-primary-text", loadedConfig.colors.primary || "#0065a1"); + setupColorPicker("color-primary-dark", "color-primary-dark-btn", "color-primary-dark-text", loadedConfig.colors.primary_dark || "#005384"); + setupColorPicker("color-secondary", "color-secondary-btn", "color-secondary-text", loadedConfig.colors.secondary || "#00b0f0"); + setupColorPicker("color-accent", "color-accent-btn", "color-accent-text", loadedConfig.colors.accent || "#ffc700"); + setupColorPicker("color-text-dark", "color-text-dark-btn", "color-text-dark-text", loadedConfig.colors.text_dark || "#616161"); + setupColorPicker("color-background", "color-background-btn", "color-background-text", loadedConfig.colors.background || "#fff"); + setupColorPicker("color-browser-color", "color-browser-color-btn", "color-browser-color-text", loadedConfig.colors.browser_color || "#fff"); } // Fonts function refreshFontDropdowns() { - setFontDropdown("font-primary", document.getElementById("font-primary").value, [ + setFontDropdown("font-primary", loadedConfig.fonts?.primary?.name || "Lato", [ ...googleFonts.map(f => f.family), ...localFonts ]); - setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [ + setFontDropdown("font-secondary", loadedConfig.fonts?.secondary?.name || "Montserrat", [ ...googleFonts.map(f => f.family), ...localFonts ]); + setFallbackDropdown("font-primary-fallback", loadedConfig.fonts?.primary?.fallback || "sans-serif"); + setFallbackDropdown("font-secondary-fallback", loadedConfig.fonts?.secondary?.fallback || "serif"); } - if (themeYaml.fonts) { - setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [ - ...googleFonts.map(f => f.family), - ...localFonts - ]); - setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif"); - setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [ - ...googleFonts.map(f => f.family), - ...localFonts - ]); - setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif"); - } + refreshFontDropdowns(); // Font upload logic const fontUploadInput = document.getElementById("font-upload"); const chooseFontBtn = document.getElementById("choose-font-btn"); - const fontUploadStatus = document.getElementById("font-upload-status"); const localFontsList = document.getElementById("local-fonts-list"); // Modal logic for font deletion @@ -341,9 +462,9 @@ document.addEventListener("DOMContentLoaded", async () => { }; } - if (themeYaml.favicon && themeYaml.favicon.path) { - faviconInput.value = themeYaml.favicon.path; - updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`); + if (loadedConfig.favicon && loadedConfig.favicon.path) { + faviconInput.value = loadedConfig.favicon.path; + updateFaviconPreview(`/themes/${themeInfo.theme_name}/${loadedConfig.favicon.path}?t=${Date.now()}`); } else { updateFaviconPreview(""); } @@ -367,6 +488,7 @@ document.addEventListener("DOMContentLoaded", async () => { googleFonts.push(...updatedGoogleFonts); renderGoogleFonts(googleFonts); refreshFontDropdowns(); + updateSectionStatus("google-fonts", loadedConfig); }); } @@ -396,6 +518,7 @@ document.addEventListener("DOMContentLoaded", async () => { googleFonts.push(...updatedGoogleFonts); renderGoogleFonts(googleFonts); refreshFontDropdowns(); + updateSectionStatus("google-fonts", loadedConfig); } }, true); @@ -414,54 +537,72 @@ document.addEventListener("DOMContentLoaded", async () => { googleFonts.push(...updatedGoogleFonts); renderGoogleFonts(googleFonts); refreshFontDropdowns(); + updateSectionStatus("google-fonts", loadedConfig); } }); } - document.getElementById("theme-editor-form").addEventListener("submit", async (e) => { - e.preventDefault(); - showLoader("Saving theme..."); - const data = {}; - data.colors = { - primary: document.getElementById("color-primary-text").value, - primary_dark: document.getElementById("color-primary-dark-text").value, - secondary: document.getElementById("color-secondary-text").value, - accent: document.getElementById("color-accent-text").value, - text_dark: document.getElementById("color-text-dark-text").value, - background: document.getElementById("color-background-text").value, - browser_color: document.getElementById("color-browser-color-text").value - }; - data.fonts = { - primary: { - name: document.getElementById("font-primary").value, - fallback: document.getElementById("font-primary-fallback").value - }, - secondary: { - name: document.getElementById("font-secondary").value, - fallback: document.getElementById("font-secondary-fallback").value - } - }; - data.favicon = { - path: faviconInput.value - }; - data.google_fonts = []; - document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => { - const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value; - const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value - .split(",").map(w => w.trim()).filter(w => w); - if (family) data.google_fonts.push({ family, weights }); - }); - - const res = await fetch("/api/theme-info", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data }) - }); - hideLoader(); - if (res.ok) { - showToast("✅ Theme saved!", "success"); - } else { - showToast("Error saving theme.", "error"); - } + // --- Section status listeners --- + [ + { form: document.getElementById("colors-form"), section: "colors" }, + { form: document.getElementById("google-fonts-form"), section: "google-fonts" }, + { form: document.getElementById("fonts-form"), section: "fonts" }, + { form: document.getElementById("favicon-form"), section: "favicon" } + ].forEach(({ form, section }) => { + if (!form) return; + form.addEventListener("input", () => updateSectionStatus(section, loadedConfig)); + form.addEventListener("change", () => updateSectionStatus(section, loadedConfig)); }); + + // --- Section save handlers --- + [ + { form: document.getElementById("colors-form"), section: "colors" }, + { form: document.getElementById("google-fonts-form"), section: "google-fonts" }, + { form: document.getElementById("fonts-form"), section: "fonts" }, + { form: document.getElementById("favicon-form"), section: "favicon" } + ].forEach(({ form, section }) => { + if (!form) return; + form.addEventListener("submit", async (e) => { + e.preventDefault(); + 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)); }); \ No newline at end of file diff --git a/src/webui/js/upload.js b/src/webui/js/upload.js index d2a45f1..27e745f 100644 --- a/src/webui/js/upload.js +++ b/src/webui/js/upload.js @@ -11,54 +11,37 @@ function hideLoader() { if (loader) loader.classList.remove("active"); } -// --- Upload gallery images --- -const galleryInput = document.getElementById('upload-gallery'); -if (galleryInput) { - galleryInput.addEventListener('change', async (e) => { +// --- Generic upload handler --- +function setupUpload(inputId, apiUrl, loaderText, successMsg, refreshFn) { + const input = document.getElementById(inputId); + if (!input) return; + input.addEventListener('change', async (e) => { const files = e.target.files; if (!files.length) return; - showLoader("Uploading photos..."); + showLoader(loaderText); const formData = new FormData(); for (const file of files) formData.append('files', file); try { - const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData }); + const res = await fetch(apiUrl, { method: 'POST', body: formData }); const data = await res.json(); hideLoader(); if (res.ok) { - showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success"); - if (typeof refreshGallery === "function") refreshGallery(); + showToast(`✅ ${data.uploaded.length} ${successMsg}`, "success"); + if (typeof refreshFn === "function") refreshFn(); } else showToast('Error: ' + data.error, "error"); } catch(err) { hideLoader(); console.error(err); showToast('Server error!', "error"); - } finally { e.target.value = ''; } + } finally { + e.target.value = ''; + } }); } -// --- Upload hero images --- -const heroInput = document.getElementById('upload-hero'); -if (heroInput) { - heroInput.addEventListener('change', async (e) => { - const files = e.target.files; - if (!files.length) return; - showLoader("Uploading hero photos..."); - const formData = new FormData(); - for (const file of files) formData.append('files', file); - - try { - const res = await fetch('/api/hero/upload', { method: 'POST', body: formData }); - const data = await res.json(); - hideLoader(); - if (res.ok) { - showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success"); - if (typeof refreshHero === "function") refreshHero(); - } else showToast('Error: ' + data.error, "error"); - } catch(err) { - hideLoader(); - console.error(err); - showToast('Server error!', "error"); - } finally { e.target.value = ''; } - }); -} \ No newline at end of file +// --- Setup all upload inputs --- +setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery); +setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero); +setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery); +setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero); \ No newline at end of file diff --git a/src/webui/site-info/index.html b/src/webui/site-info/index.html index 564e31b..eaf5d05 100644 --- a/src/webui/site-info/index.html +++ b/src/webui/site-info/index.html @@ -4,176 +4,204 @@ {% block content %} -

Edit Site Info

-
- -
-

Info

-

Set the basic information for your site and SEO

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-

Social

-

Set your social media links and thumbnail for link sharing

-
-
- - - - - -
- - - -
-
-
-
- -
-

Menu

-

Manage your site menu items. You can use tag combination to propose custom filters

-
-
- - -
-
-
- -
-

Footer

-

Set your copyright informations and legal link name

-
-
- - -
-
- - -
-
-
- -
-

Legals

-

Set your legal informations

-
-
- - -
-
- - -
-
- - -
-
- -
- -
-
-
- -
-

Build

-

Select a theme from the dropdown menu or add your custom theme folder

-
-
- - - - - - -

If checked, images will be converted for web and resized to fit the theme

- - -
-
-
- -
-
-

Steps

-

Follow the steps to generate your static gallery

- +

Edit Site Info

+ + +
+
+

Info

+

+

Set the basic information for your site and SEO

+
+
+ +
-
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + +
+
+

Social

+

+

Set your social media links and thumbnail for link sharing

+
+
+ + + + + +
+ + + +
+
+
+ +
+
+ + + + + + + + +
+
+

Legals

+

+

Set your legal informations

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+
+ + +
+
+

Build

+

+

Select a theme from the dropdown menu or add your custom theme folder

+
+
+ + + + + + +

If checked, images will be converted for web and resized to fit the theme

+ + +
+
+ +
+
+ + +
+

Steps

+

Follow the steps to generate your static gallery

+ +
-
- + +
+

Steps

+

Follow the steps to generate your static gallery

+ +
+ + + + {% endblock %}