17 Commits
Author SHA1 Message Date
Djeex fc5b1c4234 Merge pull request 'Hotfix - HDR artefacts and ICC issues' (#27) from dev into main
Reviewed-on: #27
2026-08-17 15:27:39 +02:00
Djeex 2968e875a7 Add Lumeex-web link to README.MD 2026-08-17 15:20:06 +02:00
Djeex a7ce49b989 v2.1.2 2026-08-17 15:13:36 +02:00
Djeex 26e3ca684b Fixed: Preserve embedded ICC color profiles when converting and resizing images
Image.open() reads the source's ICC profile into img.info, but that dict resets to empty after convert() or resize(), and save() was never passing icc_profile anyway. DCI-P3 and Rec.2020 photos were silently falling back to sRGB interpretation once processed.
2026-08-17 15:13:36 +02:00
Djeex a938234495 Merge pull request 'Hotfix - Chrome HDR artefacting fix' (#26) from dev into main
Reviewed-on: #26
2026-08-16 23:06:36 +02:00
Djeex 193b1704f8 Chrome HDR artefacting fix 2026-08-16 22:43:02 +02:00
Djeex 9d56d729c1 v2.1.2 2026-08-16 22:43:02 +02:00
Djeex 40a01c4701 Merge pull request 'Multi-stage smaller alpine docker image and python 3.13' (#24) from alpine into main
Reviewed-on: #24
2025-09-19 22:51:22 +02:00
Djeex 28e3b087b0 Alpine - smaller image 2025-09-19 22:40:30 +02:00
Djeex 116a9574ce Merge branch 'Beta-2.1' 2025-09-04 11:23:59 +00:00
Djeex 6c7216c0e1 Hotfix - default copy 2025-09-04 13:18:42 +02:00
Djeex 7c98a6ceaf Merge pull request 'Beta-2.1 - The clearer, the faster' (#22) from Beta-2.1 into main
Reviewed-on: #22
2025-09-04 12:50:26 +02:00
Djeex 69d303c7a1 Refactored site-info 2025-09-04 12:28:01 +02:00
Djeex 1d1d7d8304 Refactored Galley Editor 2025-09-04 12:22:52 +02:00
Djeex 205dcae2bc Refactored upload.js 2025-09-04 12:16:08 +02:00
Djeex b7fdcacf77 Fixed wrong focus and suggestion drop down delay 2025-09-04 12:11:58 +02:00
Djeex 9cdf6bbd32 Fixed hero images 2025-09-04 11:10:04 +02:00
9 changed files with 286 additions and 536 deletions
+11 -6
View File
@@ -1,10 +1,18 @@
FROM python:3.11-slim
FROM python:3.13-alpine AS builder
WORKDIR /app
COPY requirements.txt .
RUN apk add --no-cache gcc musl-dev jpeg-dev zlib-dev
RUN pip install --no-cache-dir -r requirements.txt
COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir=/wheels -r requirements.txt
FROM python:3.13-alpine
WORKDIR /app
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir --find-links=/wheels /wheels/* && rm -rf /wheels
COPY build.py gallery.py VERSION /app/
COPY ./src/ ./src/
@@ -12,7 +20,4 @@ COPY ./config /app/default
COPY ./docker/.sh/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
RUN printf '#!/bin/sh\n/app/entrypoint.sh build\n' > /usr/local/bin/build && chmod +x /usr/local/bin/build && \
printf '#!/bin/sh\n/app/entrypoint.sh gallery\n' > /usr/local/bin/gallery && chmod +x /usr/local/bin/gallery
ENTRYPOINT ["/app/entrypoint.sh"]
+2
View File
@@ -12,6 +12,8 @@
Lumeex is a static site generator designed to create minimalist photo galleries that highlight your artworks over the author. It empowers users to organize and explore images using tags, with each page load presenting photos in a random order to encourage discovery of new content.
👉 __Check the online version : [Lumeex-web](https://lumeex.djeex.fr)__
The project includes two thoughtfully designed themes—one modern, one minimalistic—both crafted to keep the spotlight on your photos:
- **Modern** — [View Demo](https://modern.djeex.fr)
+1 -1
View File
@@ -1 +1 @@
2.1.0
2.1.2
+6 -7
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
set -e
CYAN="\033[1;36m"
@@ -6,27 +6,26 @@ NC="\033[0m"
copy_default_config() {
echo "[~] Checking configuration directory..."
if [ ! -d "/app/config" ]; then
mkdir -p /app/config
fi
echo "[~] Checking if default config files/folders need to be copied..."
files_copied=false
# Recursively check all files and folders in /app/default
while IFS= read -r src; do
for src in $(find /app/default -mindepth 1); do
relpath="${src#/app/default/}"
target="/app/config/$relpath"
if [ ! -e "$target" ]; then
echo "[→] Copying: $relpath"
if [ -d "$src" ]; then
cp -r "$src" "$target"
mkdir -p "$target"
cp -r "$src/" "$target/"
else
mkdir -p "$(dirname "$target")"
cp "$src" "$target"
fi
files_copied=true
fi
done < <(find /app/default -mindepth 1)
done
if [ "$files_copied" = true ]; then
echo "[✓] Default configuration files/folders copied successfully."
+8
View File
@@ -32,6 +32,14 @@ window.addEventListener("DOMContentLoaded", () => {
img.classList.add("loaded");
};
// Chrome HDR artefacting fix
const clearWillChange = (e) => {
if (e.propertyName !== "transform") return;
img.style.willChange = "auto";
img.removeEventListener("transitionend", clearWillChange);
};
img.addEventListener("transitionend", clearWillChange);
if (img.complete && img.naturalHeight !== 0) {
onLoad(); // already loaded
} else {
+5 -1
View File
@@ -11,6 +11,7 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
return
img = Image.open(input_path)
icc_profile = img.info.get("icc_profile")
if img.mode != "RGB":
img = img.convert("RGB")
@@ -27,7 +28,10 @@ def convert_and_resize_image(input_path, output_path, resize=True, max_width=114
if fmt == "JPEG":
output_path = output_path.with_suffix(".jpg")
img.save(output_path, fmt, quality=90 if fmt == "JPEG" else 100)
save_kwargs = {"quality": 90 if fmt == "JPEG" else 100}
if icc_profile:
save_kwargs["icc_profile"] = icc_profile
img.save(output_path, fmt, **save_kwargs)
logging.info(f"[✓] Processed image: {input_path}{output_path}")
except Exception as e:
+76 -162
View File
@@ -1,19 +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);
@@ -25,25 +35,38 @@ async function loadData() {
function updateAllTags() {
allTags = [];
galleryImages.forEach(img => {
if (img.tags) img.tags.forEach(t => {
(img.tags || []).forEach(t => {
if (!allTags.includes(t)) allTags.push(t);
});
});
}
// --- Helper: update count and button visibility ---
function updateCountAndButtons(prefix, count) {
const countTop = document.getElementById(`${prefix}-count`);
const countBottom = document.getElementById(`${prefix}-count-bottom`);
if (countTop) countTop.innerHTML = `<p>${count} photos</p>`;
if (countBottom) countBottom.innerHTML = `<p>${count} photos</p>`;
const removeAllBtn = document.getElementById(`remove-all-${prefix}`);
const removeAllBtnBottom = document.getElementById(`remove-all-${prefix}-bottom`);
if (removeAllBtn) removeAllBtn.style.display = count > 0 ? 'inline-block' : 'none';
if (removeAllBtnBottom) removeAllBtnBottom.style.display = count > 0 ? 'inline-block' : 'none';
const bottomUpload = document.getElementById(`bottom-${prefix}-upload`);
if (bottomUpload) bottomUpload.style.display = count > 0 ? 'flex' : 'none';
}
// --- Render gallery images with tags and delete buttons ---
function renderGallery() {
const container = document.getElementById('gallery');
container.innerHTML = '';
// Filter images if toggle is on
let imagesToShow = galleryImages;
if (showOnlyUntagged) {
imagesToShow = galleryImages.filter(img => !img.tags || img.tags.length === 0);
}
let imagesToShow = showOnlyUntagged
? galleryImages.filter(img => !img.tags || img.tags.length === 0)
: galleryImages;
imagesToShow.forEach((img) => {
const i = galleryImages.indexOf(img); // Use real index for tag/delete actions
const i = galleryImages.indexOf(img);
const div = document.createElement('div');
div.className = 'photo flex-item flex-column';
div.innerHTML = `
@@ -59,62 +82,17 @@ function renderGallery() {
</div>
`;
container.appendChild(div);
renderTags(i, img.tags || []);
});
// Update gallery count (top)
const galleryCount = document.getElementById('gallery-count');
if (galleryCount) {
galleryCount.innerHTML = `<p>${imagesToShow.length} photos</p>`;
}
// Update gallery count (bottom)
const galleryCountBottom = document.getElementById('gallery-count-bottom');
if (galleryCountBottom) {
galleryCountBottom.innerHTML = `<p>${imagesToShow.length} photos</p>`;
}
// Show/hide Remove All button (top)
const removeAllBtn = document.getElementById('remove-all-gallery');
if (removeAllBtn) {
removeAllBtn.style.display = imagesToShow.length > 0 ? 'inline-block' : 'none';
}
// Show/hide bottom upload row
const bottomGalleryUpload = document.getElementById('bottom-gallery-upload');
if (bottomGalleryUpload) {
bottomGalleryUpload.style.display = imagesToShow.length > 0 ? 'flex' : 'none';
}
// Show/hide Remove All button (bottom)
const removeAllBtnBottom = document.getElementById('remove-all-gallery-bottom');
if (removeAllBtnBottom) {
removeAllBtnBottom.style.display = imagesToShow.length > 0 ? 'inline-block' : 'none';
}
// Fade-in effect for loaded images
const fadeImages = document.querySelectorAll("img.fade-in-img");
fadeImages.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);
});
}
});
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 = '';
@@ -122,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 = '×';
@@ -131,7 +108,6 @@ function renderTags(imgIndex, tags) {
updateTags(imgIndex, tags);
renderTags(imgIndex, tags);
};
span.appendChild(remove);
tagsDisplay.appendChild(span);
});
@@ -141,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);
@@ -165,30 +140,20 @@ function renderTags(imgIndex, tags) {
const updateSuggestions = () => {
const value = input.value.toLowerCase();
const allTagsFlat = galleryImages.flatMap(img => img.tags || []);
const tagCount = {};
allTagsFlat.forEach(t => tagCount[t] = (tagCount[t] || 0) + 1);
const allTagsSorted = Object.keys(tagCount)
.sort((a, b) => tagCount[b] - tagCount[a]);
const allTagsSorted = Object.keys(tagCount).sort((a, b) => tagCount[b] - tagCount[a]);
const suggestions = allTagsSorted.filter(t => t.toLowerCase().startsWith(value) && !tags.includes(t));
suggestionBox.innerHTML = '';
selectedIndex = -1;
if (suggestions.length) {
suggestionBox.style.display = 'block';
suggestions.forEach((s, idx) => {
const li = document.createElement('li');
li.style.fontStyle = 'italic';
li.style.textAlign = 'left';
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
const rest = s.substring(input.value.length);
li.innerHTML = boldPart + rest;
li.innerHTML = `<b>${s.substring(0, input.value.length)}</b>${s.substring(input.value.length)}`;
li.addEventListener('mousedown', (e) => {
e.preventDefault();
addTag(s);
@@ -196,7 +161,6 @@ function renderTags(imgIndex, tags) {
input.focus();
updateSuggestions();
});
li.onmouseover = () => selectedIndex = idx;
suggestionBox.appendChild(li);
});
@@ -213,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') {
@@ -244,27 +207,22 @@ function renderTags(imgIndex, tags) {
validateBtn.style.display = 'none';
}
});
input.addEventListener('blur', () => {
setTimeout(() => {
suggestionBox.style.display = 'none';
input.value = '';
validateBtn.style.display = 'none';
}, 150);
});
// --- Validate button action ---
validateBtn.onclick = () => {
validateBtn.addEventListener('mousedown', (e) => {
e.preventDefault();
if (input.value.trim()) {
addTag(input.value.trim());
input.value = '';
updateSuggestions();
validateBtn.style.display = 'none';
}
};
input.focus();
});
updateSuggestions();
if (!input.value.trim()) suggestionBox.style.display = 'none';
}
// --- Update tags in galleryImages array ---
@@ -292,36 +250,8 @@ function renderHero() {
`;
container.appendChild(div);
});
// Update hero count (top)
const heroCount = document.getElementById('hero-count');
if (heroCount) {
heroCount.innerHTML = `<p>${heroImages.length} photos</p>`;
}
// Update hero count (bottom)
const heroCountBottom = document.getElementById('hero-count-bottom');
if (heroCountBottom) {
heroCountBottom.innerHTML = `<p>${heroImages.length} photos</p>`;
}
// Show/hide Remove All button (top)
const removeAllBtn = document.getElementById('remove-all-hero');
if (removeAllBtn) {
removeAllBtn.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
}
// Show/hide bottom upload row
const bottomHeroUpload = document.getElementById('bottom-hero-upload');
if (bottomHeroUpload) {
bottomHeroUpload.style.display = heroImages.length > 0 ? 'flex' : 'none';
}
// Show/hide Remove All button (bottom)
const removeAllBtnBottom = document.getElementById('remove-all-hero-bottom');
if (removeAllBtnBottom) {
removeAllBtnBottom.style.display = heroImages.length > 0 ? 'inline-block' : 'none';
}
updateCountAndButtons('hero', heroImages.length);
applyFadeInImages(container);
}
// --- Save gallery to server ---
@@ -367,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';
}
@@ -406,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();
}
@@ -496,41 +415,36 @@ 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;
// --- Radio toggle for gallery filter ---
// Gallery filter radios
const showAllRadio = document.getElementById('show-all-radio');
const showUntaggedRadio = document.getElementById('show-untagged-radio');
if (showAllRadio && showUntaggedRadio) {
showAllRadio.addEventListener('change', () => {
if (showAllRadio.checked) {
if (showAllRadio) showAllRadio.addEventListener('change', () => {
showOnlyUntagged = false;
renderGallery();
}
});
showUntaggedRadio.addEventListener('change', () => {
if (showUntaggedRadio.checked) {
if (showUntaggedRadio) showUntaggedRadio.addEventListener('change', () => {
showOnlyUntagged = true;
renderGallery();
}
});
}
// Bulk delete buttons
const removeAllGalleryBtn = document.getElementById('remove-all-gallery');
const removeAllGalleryBtnBottom = document.getElementById('remove-all-gallery-bottom');
const removeAllHeroBtn = document.getElementById('remove-all-hero');
const removeAllHeroBtnBottom = document.getElementById('remove-all-hero-bottom');
if (removeAllGalleryBtn) removeAllGalleryBtn.onclick = () => showDeleteModal('gallery-all');
if (removeAllGalleryBtnBottom) removeAllGalleryBtnBottom.onclick = () => showDeleteModal('gallery-all');
if (removeAllHeroBtn) removeAllHeroBtn.onclick = () => showDeleteModal('hero-all');
if (removeAllHeroBtnBottom) removeAllHeroBtnBottom.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 ---
loadData();
+110 -222
View File
@@ -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) {
@@ -27,77 +26,65 @@ function hideLoader() {
document.addEventListener("DOMContentLoaded", () => {
// --- Section Forms ---
const infoForm = document.getElementById("info-form");
const socialForm = document.getElementById("social-form");
const menuForm = document.getElementById("menu-form");
const footerForm = document.getElementById("footer-form");
const legalsForm = document.getElementById("legals-form");
const buildForm = document.getElementById("build-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 addMenuBtn = document.getElementById("add-menu-item");
let menuItems = [];
function renderMenuItems() {
menuList.innerHTML = "";
menuItems.forEach((item, idx) => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
menuList.innerHTML += `
<div style="display:flex;gap:8px;margin-bottom:6px;">
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
</div>
`;
menuList.appendChild(div);
});
}
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 ---
const ipList = document.getElementById("ip-list");
const addIpBtn = document.getElementById("add-ip-paragraph");
let ipParagraphs = [];
function renderIpParagraphs() {
ipList.innerHTML = "";
ipParagraphs.forEach((item, idx) => {
const div = document.createElement("div");
div.style.display = "flex";
div.style.gap = "8px";
div.style.marginBottom = "6px";
div.innerHTML = `
ipList.innerHTML += `
<div style="display:flex;gap:8px;margin-bottom:6px;">
<textarea placeholder="Paragraph" required style="flex:1;" data-idx="${idx}">${item.paragraph || ""}</textarea>
<button type="button" class="remove-ip-paragraph" data-idx="${idx}">🗑</button>
</div>
`;
ipList.appendChild(div);
});
}
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 ---
@@ -107,40 +94,30 @@ document.addEventListener("DOMContentLoaded", () => {
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];
@@ -161,25 +138,17 @@ document.addEventListener("DOMContentLoaded", () => {
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") {
@@ -189,41 +158,28 @@ 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");
}
@@ -231,8 +187,9 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// --- 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;
@@ -242,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", {
@@ -271,7 +220,18 @@ document.addEventListener("DOMContentLoaded", () => {
hideLoader();
if (result.status === "ok") {
showToast("✅ Theme removed!", "success");
// Refresh theme select
refreshThemes();
} else {
showToast(result.error || "❌ Error removing theme", "error");
}
document.getElementById("delete-theme-modal").style.display = "none";
themeToDelete = null;
updateSectionStatus("build");
}
);
// --- Theme select refresh ---
function refreshThemes() {
fetch("/api/themes")
.then(res => res.json())
.then(themes => {
@@ -282,17 +242,11 @@ document.addEventListener("DOMContentLoaded", () => {
option.textContent = theme;
themeSelect.appendChild(option);
});
loadConfigAndUpdateBuildStatus();
});
} else {
showToast(result.error || "❌ Error removing theme", "error");
}
deleteThemeModal.style.display = "none";
themeToDelete = null;
updateSectionStatus("build");
};
}
// --- Fetch theme list and populate select, then load config and update build status ---
// --- Config loading ---
let loadedConfig = {};
function loadConfigAndUpdateBuildStatus() {
fetch("/api/site-info")
@@ -300,17 +254,17 @@ document.addEventListener("DOMContentLoaded", () => {
.then(data => {
loadedConfig = data;
// Info
if (infoForm) {
infoForm.elements["info.title"].value = data.info?.title || "";
infoForm.elements["info.subtitle"].value = data.info?.subtitle || "";
infoForm.elements["info.description"].value = data.info?.description || "";
infoForm.elements["info.canonical"].value = data.info?.canonical || "";
infoForm.elements["info.keywords"].value = Array.isArray(data.info?.keywords) ? data.info.keywords.join(", ") : (data.info?.keywords || "");
infoForm.elements["info.author"].value = data.info?.author || "";
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 (socialForm) {
socialForm.elements["social.instagram_url"].value = data.social?.instagram_url || "";
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()}` : "");
}
@@ -318,66 +272,37 @@ document.addEventListener("DOMContentLoaded", () => {
menuItems = Array.isArray(data.menu?.items) ? data.menu.items : [];
renderMenuItems();
// Footer
if (footerForm) {
footerForm.elements["footer.copyright"].value = data.footer?.copyright || "";
footerForm.elements["footer.legal_label"].value = data.footer?.legal_label || "";
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();
if (legalsForm) {
legalsForm.elements["legals.hoster_name"].value = data.legals?.hoster_name || "";
legalsForm.elements["legals.hoster_address"].value = data.legals?.hoster_address || "";
legalsForm.elements["legals.hoster_contact"].value = data.legals?.hoster_contact || "";
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;
}
// Initial status update for all sections except 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);
// For build, update status after theme select is set
updateSectionStatus("build");
});
}
if (themeSelect) refreshThemes();
else loadConfigAndUpdateBuildStatus();
if (themeSelect) {
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);
});
// Now load config and update build status after theme select is ready
loadConfigAndUpdateBuildStatus();
});
} else {
// If no theme select, just load config
loadConfigAndUpdateBuildStatus();
}
// --- Add menu item ---
if (addMenuBtn) {
addMenuBtn.addEventListener("click", () => {
// --- Add/remove menu items ---
if (addMenuBtn) addMenuBtn.addEventListener("click", () => {
menuItems.push({ label: "", href: "" });
renderMenuItems();
updateSectionStatus("menu");
});
}
// --- Remove menu item ---
menuList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-menu-item")) {
const idx = parseInt(e.target.getAttribute("data-idx"));
@@ -386,23 +311,17 @@ document.addEventListener("DOMContentLoaded", () => {
updateSectionStatus("menu");
}
});
// --- Update menuItems on input change ---
menuList.addEventListener("input", () => {
updateMenuItemsFromInputs();
updateSectionStatus("menu");
});
// --- Add paragraph ---
if (addIpBtn) {
addIpBtn.addEventListener("click", () => {
// --- Add/remove IP paragraphs ---
if (addIpBtn) addIpBtn.addEventListener("click", () => {
ipParagraphs.push({ paragraph: "" });
renderIpParagraphs();
updateSectionStatus("legals");
});
}
// --- Remove paragraph ---
ipList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-ip-paragraph")) {
const idx = parseInt(e.target.getAttribute("data-idx"));
@@ -411,8 +330,6 @@ document.addEventListener("DOMContentLoaded", () => {
updateSectionStatus("legals");
}
});
// --- Update ipParagraphs on input change ---
ipList.addEventListener("input", () => {
updateIpParagraphsFromInputs();
updateSectionStatus("legals");
@@ -423,16 +340,16 @@ document.addEventListener("DOMContentLoaded", () => {
switch (section) {
case "info":
return {
title: infoForm.elements["info.title"].value,
subtitle: infoForm.elements["info.subtitle"].value,
description: infoForm.elements["info.description"].value,
canonical: infoForm.elements["info.canonical"].value,
keywords: infoForm.elements["info.keywords"].value.split(",").map(i => i.trim()).filter(Boolean),
author: infoForm.elements["info.author"].value
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: socialForm.elements["social.instagram_url"].value,
instagram_url: forms.social.elements["social.instagram_url"].value,
thumbnail: thumbnailInput ? thumbnailInput.value : ""
};
case "menu":
@@ -440,15 +357,15 @@ document.addEventListener("DOMContentLoaded", () => {
return { items: menuItems };
case "footer":
return {
copyright: footerForm.elements["footer.copyright"].value,
legal_label: footerForm.elements["footer.legal_label"].value
copyright: forms.footer.elements["footer.copyright"].value,
legal_label: forms.footer.elements["footer.legal_label"].value
};
case "legals":
updateIpParagraphsFromInputs();
return {
hoster_name: legalsForm.elements["legals.hoster_name"].value,
hoster_address: legalsForm.elements["legals.hoster_address"].value,
hoster_contact: legalsForm.elements["legals.hoster_contact"].value,
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":
@@ -533,7 +450,7 @@ document.addEventListener("DOMContentLoaded", () => {
values.intellectual_property.every(ip => ip.paragraph)
);
case "build":
return !!values.theme; // Only check theme is present
return !!values.theme;
default:
return true;
}
@@ -561,46 +478,22 @@ document.addEventListener("DOMContentLoaded", () => {
}
// --- Listen for changes in each section ---
[
{ form: infoForm, section: "info" },
{ form: socialForm, section: "social" },
{ form: menuForm, section: "menu" },
{ form: footerForm, section: "footer" },
{ form: legalsForm, section: "legals" },
{ form: buildForm, section: "build" }
].forEach(({ form, section }) => {
Object.entries(forms).forEach(([section, form]) => {
if (!form) return;
form.addEventListener("input", () => updateSectionStatus(section));
form.addEventListener("change", () => updateSectionStatus(section));
});
// --- Save section handler (form submit) ---
[
{ form: infoForm, section: "info" },
{ form: socialForm, section: "social" },
{ form: menuForm, section: "menu" },
{ form: footerForm, section: "footer" },
{ form: legalsForm, section: "legals" },
{ form: buildForm, section: "build" }
].forEach(({ form, section }) => {
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
// Native browser validation
if (!form.reportValidity()) {
showToast("❌ Please fill all required fields before saving.", "error");
updateSectionStatus(section);
return;
}
// Social section: check thumbnail
if (section === "social") {
if (!thumbnailInput || !thumbnailInput.value) {
if (section === "social" && (!thumbnailInput || !thumbnailInput.value)) {
showToast("❌ Thumbnail is required.", "error");
updateSectionStatus(section);
return;
}
}
// Menu section: check all menu items
if (section === "menu") {
updateMenuItemsFromInputs();
if (!menuItems.length || !menuItems.every(item => item.label && item.href)) {
@@ -609,7 +502,6 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
}
// Legals section: check all paragraphs
if (section === "legals") {
updateIpParagraphsFromInputs();
if (!ipParagraphs.length || !ipParagraphs.every(ip => ip.paragraph)) {
@@ -618,10 +510,8 @@ document.addEventListener("DOMContentLoaded", () => {
return;
}
}
// Build payload for this section only
let payload = {};
payload[section] = getSectionValues(section);
const res = await fetch("/api/site-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -630,7 +520,6 @@ document.addEventListener("DOMContentLoaded", () => {
const result = await res.json();
if (result.status === "ok") {
showToast("✅ Section saved!", "success");
// Reload config for this section
fetch("/api/site-info")
.then(res => res.json())
.then(data => {
@@ -642,5 +531,4 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
});
});
+17 -87
View File
@@ -11,107 +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 = ''; }
});
}
// --- Upload gallery images (bottom button) ---
const galleryInputBottom = document.getElementById('upload-gallery-bottom');
if (galleryInputBottom) {
galleryInputBottom.addEventListener('change', async (e) => {
const files = e.target.files;
if (!files.length) return;
showLoader("Uploading photos...");
const formData = new FormData();
for (const file of files) formData.append('files', file);
try {
const res = await fetch('/api/gallery/upload', { 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();
} else showToast('Error: ' + data.error, "error");
} catch(err) {
hideLoader();
console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; }
});
}
// --- Upload hero images (bottom button) ---
const heroInputBottom = document.getElementById('upload-hero-bottom');
if (heroInputBottom) {
heroInputBottom.addEventListener('change', async (e) => {
const files = e.target.files;
if (!files.length) return;
showLoader("Uploading hero photos...");
const formData = new FormData();
for (const file of files) formData.append('files', file);
try {
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
const data = await res.json();
hideLoader();
if (res.ok) {
showToast(`${data.uploaded.length} hero image(s) uploaded!`, "success");
if (typeof refreshHero === "function") refreshHero();
} else showToast('Error: ' + data.error, "error");
} catch(err) {
hideLoader();
console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; }
});
}
// --- Setup all upload inputs ---
setupUpload('upload-gallery', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
setupUpload('upload-hero', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);
setupUpload('upload-gallery-bottom', '/api/gallery/upload', "Uploading photos...", "gallery image(s) uploaded!", refreshGallery);
setupUpload('upload-hero-bottom', '/api/hero/upload', "Uploading hero photos...", "hero image(s) uploaded!", refreshHero);