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 @@ + +

Photo WebUI

-
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