diff --git a/src/webui/index.html b/src/webui/index.html
index 59c0cb6..4241205 100644
--- a/src/webui/index.html
+++ b/src/webui/index.html
@@ -8,8 +8,9 @@
+
+
diff --git a/src/webui/js/main.js b/src/webui/js/main.js
index 9d349de..d236443 100644
--- a/src/webui/js/main.js
+++ b/src/webui/js/main.js
@@ -1,12 +1,14 @@
// --- Arrays to store gallery and hero images ---
let galleryImages = [];
let heroImages = [];
+let allTags = []; // global tag list
// --- Load images from server on page load ---
async function loadData() {
try {
const galleryRes = await fetch('/api/gallery');
galleryImages = await galleryRes.json();
+ updateAllTags();
renderGallery();
const heroRes = await fetch('/api/hero');
@@ -14,10 +16,20 @@ async function loadData() {
renderHero();
} catch(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 ---
function renderGallery() {
const container = document.getElementById('gallery');
@@ -27,14 +39,132 @@ function renderGallery() {
div.className = 'photo';
div.innerHTML = `

-
+
`;
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 = `
${s.substring(0, input.value.length)}`;
+ 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 ---
function renderHero() {
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 ---
async function deleteGalleryImage(index) {
const img = galleryImages[index];
@@ -69,9 +194,11 @@ async function deleteGalleryImage(index) {
galleryImages.splice(index, 1);
renderGallery();
await saveGallery();
- } else alert("Error: " + data.error);
+ showToast("✅ Gallery image deleted!", "success");
+ } else showToast("Error: " + data.error, "error");
} 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);
renderHero();
await saveHero();
- } else alert("Error: " + data.error);
+ showToast("✅ Hero image deleted!", "success");
+ } else showToast("Error: " + data.error, "error");
} 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() {
await saveGallery();
await saveHero();
- alert('✅ Changes saved!');
+ showToast('✅ Changes saved!', "success");
}
// --- Refresh gallery from folder ---
async function refreshGallery() {
await fetch('/api/gallery/refresh', { method: 'POST' });
await loadData();
- alert('🔄 Gallery updated from photos/gallery folder');
+ showToast('🔄 Gallery updated from photos/gallery folder', "success");
}
// --- Refresh hero from folder ---
async function refreshHero() {
await fetch('/api/hero/refresh', { method: 'POST' });
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 ---
diff --git a/src/webui/js/upload.js b/src/webui/js/upload.js
index 7ff8d8a..a8d76bc 100644
--- a/src/webui/js/upload.js
+++ b/src/webui/js/upload.js
@@ -10,11 +10,12 @@ document.getElementById('upload-gallery').addEventListener('change', async (e) =
const res = await fetch('/api/gallery/upload', { method: 'POST', body: formData });
const data = await res.json();
if (res.ok) {
- alert(`✅ ${data.uploaded.length} gallery image(s) uploaded!`);
+ showToast(`✅ ${data.uploaded.length} gallery image(s) uploaded!`, "success");
refreshGallery();
- } else alert('Error: ' + data.error);
+ } else showToast('Error: ' + data.error, "error");
} catch(err) {
- console.error(err); alert('Server error!');
+ console.error(err);
+ showToast('Server error!', "error");
} 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 data = await res.json();
if (res.ok) {
- alert(`✅ ${data.uploaded.length} hero image(s) uploaded!`);
+ showToast(`✅ ${data.uploaded.length} hero image(s) uploaded!`, "success");
refreshHero();
- } else alert('Error: ' + data.error);
+ } else showToast('Error: ' + data.error, "error");
} catch(err) {
- console.error(err); alert('Server error!');
+ console.error(err);
+ showToast('Server error!', "error");
} finally { e.target.value = ''; }
});
diff --git a/src/webui/style/style.css b/src/webui/style/style.css
index ec1f57a..d749c98 100644
--- a/src/webui/style/style.css
+++ b/src/webui/style/style.css
@@ -96,3 +96,104 @@ h1, h2 {
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;
+}
\ No newline at end of file