2.0 - WebUI builder ("Cielight" merge) #9

Merged
Djeex merged 43 commits from beta into main 2025-08-26 10:52:13 +02:00
20 changed files with 765 additions and 38 deletions
Showing only changes of commit 142c042b86 - Show all commits

View File

@ -8,8 +8,9 @@
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
</head> </head>
<body> <body>
<!-- Toast container for notifications -->
<div id="toast-container"></div>
<h1>Photo WebUI</h1> <h1>Photo WebUI</h1>
<!-- Toolbar with refresh and save buttons --> <!-- Toolbar with refresh and save buttons -->
<div class="toolbar"> <div class="toolbar">
<button onclick="refreshGallery()">🔄 Refresh Gallery</button> <button onclick="refreshGallery()">🔄 Refresh Gallery</button>

View File

@ -1,12 +1,14 @@
// --- Arrays to store gallery and hero images --- // --- Arrays to store gallery and hero images ---
let galleryImages = []; let galleryImages = [];
let heroImages = []; let heroImages = [];
let allTags = []; // global tag list
// --- Load images from server on page load --- // --- Load images from server on page load ---
async function loadData() { async function loadData() {
try { try {
const galleryRes = await fetch('/api/gallery'); const galleryRes = await fetch('/api/gallery');
galleryImages = await galleryRes.json(); galleryImages = await galleryRes.json();
updateAllTags();
renderGallery(); renderGallery();
const heroRes = await fetch('/api/hero'); const heroRes = await fetch('/api/hero');
@ -14,10 +16,20 @@ async function loadData() {
renderHero(); renderHero();
} catch(err) { } catch(err) {
console.error(err); console.error(err);
alert("Error loading images!"); showToast("Error loading images!", "error");
} }
} }
// --- Update global tag list from galleryImages ---
function updateAllTags() {
allTags = [];
galleryImages.forEach(img => {
if (img.tags) img.tags.forEach(t => {
if (!allTags.includes(t)) allTags.push(t);
});
});
}
// --- Render gallery images with tags and delete buttons --- // --- Render gallery images with tags and delete buttons ---
function renderGallery() { function renderGallery() {
const container = document.getElementById('gallery'); const container = document.getElementById('gallery');
@ -27,14 +39,132 @@ function renderGallery() {
div.className = 'photo'; div.className = 'photo';
div.innerHTML = ` div.innerHTML = `
<img src="/photos/${img.src}"> <img src="/photos/${img.src}">
<input type="text" value="${(img.tags || []).join(', ')}" <div class="tag-input" data-index="${i}"></div>
onchange="updateTags(${i}, this.value)">
<button onclick="deleteGalleryImage(${i})">🗑 Delete</button> <button onclick="deleteGalleryImage(${i})">🗑 Delete</button>
`; `;
container.appendChild(div); container.appendChild(div);
renderTags(div.querySelector('.tag-input'), img.tags || [], i);
}); });
} }
// --- Render tags for a single image ---
function renderTags(container, tags, imgIndex) {
container.innerHTML = '';
// Render existing tags
tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tag';
span.textContent = tag;
const remove = document.createElement('span');
remove.className = 'remove-tag';
remove.textContent = '×';
remove.onclick = () => {
tags.splice(tags.indexOf(tag), 1);
updateTags(imgIndex, tags);
renderTags(container, tags, imgIndex);
};
span.appendChild(remove);
container.appendChild(span);
});
// Input for new tags
const input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Add tag...';
container.appendChild(input);
// Suggestion dropdown
const suggestionBox = document.createElement('ul');
suggestionBox.className = 'suggestions';
suggestionBox.style.fontStyle = 'italic';
suggestionBox.style.textAlign = 'left';
container.appendChild(suggestionBox);
let selectedIndex = -1;
const addTag = (tag) => {
tag = tag.trim();
if (!tag) return;
if (!tags.includes(tag)) tags.push(tag);
updateAllTags();
updateTags(imgIndex, tags);
renderTags(container, tags, imgIndex);
};
const updateSuggestions = () => {
const value = input.value.toLowerCase();
const suggestions = allTags.filter(t => !tags.includes(t) && t.toLowerCase().startsWith(value));
suggestionBox.innerHTML = '';
selectedIndex = -1;
if (suggestions.length) {
suggestionBox.style.display = 'block';
suggestions.forEach((s, idx) => {
const li = document.createElement('li');
const boldPart = `<b>${s.substring(0, input.value.length)}</b>`;
const rest = s.substring(input.value.length);
li.innerHTML = boldPart + rest;
li.onclick = () => addTag(s);
li.onmouseover = () => selectedIndex = idx;
suggestionBox.appendChild(li);
});
} else {
suggestionBox.style.display = 'none';
}
};
input.addEventListener('input', updateSuggestions);
input.addEventListener('focus', updateSuggestions);
// Keyboard navigation
input.addEventListener('keydown', (e) => {
const items = suggestionBox.querySelectorAll('li');
if (items.length) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = (selectedIndex + 1) % items.length;
items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = (selectedIndex - 1 + items.length) % items.length;
items.forEach((li, i) => li.classList.toggle('selected', i === selectedIndex));
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0) addTag(items[selectedIndex].textContent.replace(/×$/,''));
else addTag(input.value);
} else if (e.key === 'Escape') {
suggestionBox.style.display = 'none';
} else if ([' ', ','].includes(e.key)) {
e.preventDefault();
addTag(input.value);
}
} else if (['Enter', ' ', ','].includes(e.key)) {
e.preventDefault();
addTag(input.value);
}
});
input.addEventListener('blur', () => {
setTimeout(() => {
if (input.value.trim()) addTag(input.value);
suggestionBox.style.display = 'none';
}, 100);
});
input.focus();
}
// --- Update tags in galleryImages array ---
function updateTags(index, tags) {
galleryImages[index].tags = tags;
saveGallery();
}
// --- Render hero images with delete buttons --- // --- Render hero images with delete buttons ---
function renderHero() { function renderHero() {
const container = document.getElementById('hero'); const container = document.getElementById('hero');
@ -50,11 +180,6 @@ function renderHero() {
}); });
} }
// --- Update tags for gallery image ---
function updateTags(index, value) {
galleryImages[index].tags = value.split(',').map(t => t.trim()).filter(t => t);
}
// --- Delete gallery image --- // --- Delete gallery image ---
async function deleteGalleryImage(index) { async function deleteGalleryImage(index) {
const img = galleryImages[index]; const img = galleryImages[index];
@ -69,9 +194,11 @@ async function deleteGalleryImage(index) {
galleryImages.splice(index, 1); galleryImages.splice(index, 1);
renderGallery(); renderGallery();
await saveGallery(); await saveGallery();
} else alert("Error: " + data.error); showToast("✅ Gallery image deleted!", "success");
} else showToast("Error: " + data.error, "error");
} catch(err) { } catch(err) {
console.error(err); alert("Server error!"); console.error(err);
showToast("Server error!", "error");
} }
} }
@ -89,9 +216,11 @@ async function deleteHeroImage(index) {
heroImages.splice(index, 1); heroImages.splice(index, 1);
renderHero(); renderHero();
await saveHero(); await saveHero();
} else alert("Error: " + data.error); showToast("✅ Hero image deleted!", "success");
} else showToast("Error: " + data.error, "error");
} catch(err) { } catch(err) {
console.error(err); alert("Server error!"); console.error(err);
showToast("Server error!", "error");
} }
} }
@ -117,21 +246,39 @@ async function saveHero() {
async function saveChanges() { async function saveChanges() {
await saveGallery(); await saveGallery();
await saveHero(); await saveHero();
alert('✅ Changes saved!'); showToast('✅ Changes saved!', "success");
} }
// --- Refresh gallery from folder --- // --- Refresh gallery from folder ---
async function refreshGallery() { async function refreshGallery() {
await fetch('/api/gallery/refresh', { method: 'POST' }); await fetch('/api/gallery/refresh', { method: 'POST' });
await loadData(); await loadData();
alert('🔄 Gallery updated from photos/gallery folder'); showToast('🔄 Gallery updated from photos/gallery folder', "success");
} }
// --- Refresh hero from folder --- // --- Refresh hero from folder ---
async function refreshHero() { async function refreshHero() {
await fetch('/api/hero/refresh', { method: 'POST' }); await fetch('/api/hero/refresh', { method: 'POST' });
await loadData(); await loadData();
alert('🔄 Hero updated from photos/hero folder'); showToast('🔄 Hero updated from photos/hero folder', "success");
}
// --- Show toast notification ---
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);
} }
// --- Initialize --- // --- Initialize ---

View File

@ -10,11 +10,12 @@ document.getElementById('upload-gallery').addEventListener('change', async (e) =
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData }); const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
alert(`${data.uploaded.length} gallery image(s) uploaded!`); showToast(`${data.uploaded.length} gallery image(s) uploaded!`, "success");
refreshGallery(); refreshGallery();
} else alert('Error: ' + data.error); } else showToast('Error: ' + data.error, "error");
} catch(err) { } catch(err) {
console.error(err); alert('Server error!'); console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; } } finally { e.target.value = ''; }
}); });
@ -30,10 +31,11 @@ document.getElementById('upload-hero').addEventListener('change', async (e) => {
const res = await fetch('/api/hero/upload', { method: 'POST', body: formData }); const res = await fetch('/api/hero/upload', { method: 'POST', body: formData });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
alert(`${data.uploaded.length} hero image(s) uploaded!`); showToast(`${data.uploaded.length} hero image(s) uploaded!`, "success");
refreshHero(); refreshHero();
} else alert('Error: ' + data.error); } else showToast('Error: ' + data.error, "error");
} catch(err) { } catch(err) {
console.error(err); alert('Server error!'); console.error(err);
showToast('Server error!', "error");
} finally { e.target.value = ''; } } finally { e.target.value = ''; }
}); });

View File

@ -96,3 +96,104 @@ h1, h2 {
margin-bottom: 10px; margin-bottom: 10px;
} }
} }
/* Toast notifications */
#toast-container {
position: fixed;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 9999;
}
.toast {
background: rgba(0,0,0,0.85);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 0.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.5s ease, transform 0.5s ease;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
.toast.success { background-color: #28a745; }
.toast.error { background-color: #dc3545; }
/* Tags */
.tag-input {
display: flex;
flex-wrap: wrap;
gap: 4px;
border: 1px solid #ccc;
padding: 4px;
border-radius: 4px;
position: relative; /* ensures dropdown positions correctly */
background-color: white;
z-index: 1;
}
.tag-input input {
border: none;
outline: none;
flex: 1;
min-width: 60px;
}
.tag {
background-color: #eee;
padding: 2px 6px;
border-radius: 3px;
display: flex;
align-items: center;
}
.tag .remove-tag {
margin-left: 4px;
cursor: pointer;
font-weight: bold;
}
.tag-input ul.suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #ccc;
border-top: none;
list-style: none;
margin: 0;
padding: 0;
max-height: 150px;
overflow-y: auto;
z-index: 999; /* ensure it displays above other elements */
display: none;
box-shadow: 0 4px 8px rgba(0,0,0,0.15); /* subtle shadow for visibility */
}
.tag-input ul.suggestions li {
padding: 6px 8px;
cursor: pointer;
}
.tag-input ul.suggestions li:hover {
background-color: #f0f0f0;
}
.suggestions li.selected {
background-color: #007bff;
color: white;
cursor: pointer;
}
.suggestions li {
cursor: pointer;
}