Better ui tag system
This commit is contained in:
@ -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>
|
||||||
|
@ -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 ---
|
||||||
|
@ -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 = ''; }
|
||||||
});
|
});
|
||||||
|
@ -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;
|
||||||
|
}
|
Reference in New Issue
Block a user