major updates to theming, creation of showcase view and polish of existing systems including tagging editting.

This commit is contained in:
Bryan Van Deusen
2026-01-18 11:32:21 -05:00
parent 5f568f43bc
commit 46144ccc76
21 changed files with 1787 additions and 557 deletions
+159 -8
View File
@@ -12,6 +12,12 @@ document.addEventListener('DOMContentLoaded', () => {
const tagList = document.getElementById('modalTagList');
const tagForm = document.getElementById('modalTagForm');
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
const tagAutocomplete = document.getElementById('tagAutocomplete');
// Autocomplete state
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
let autocompleteDebounce = null;
let images = Array.from(document.querySelectorAll('.img-clickable'));
let currentIndex = -1;
@@ -36,18 +42,31 @@ document.addEventListener('DOMContentLoaded', () => {
function getEditorImageId() {
return tagEditor ? tagEditor.dataset.imageId : '';
}
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
rating: '⚠️'
};
return icons[kind] || '#';
}
function getTagDisplayName(name) {
// Remove prefix if present (e.g., "artist:Name" -> "Name")
return name.includes(':') ? name.split(':', 2)[1] : name;
}
function renderTags(tags) {
if (!tagList) return;
tagList.innerHTML = '';
(tags || []).forEach(t => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
const label = (t.kind === 'artist')
? `🎨 ${t.name.split(':', 1)[0] === 'artist' ? t.name.split(':', 2)[1] : t.name}`
: (t.kind === 'archive')
? `🗜️ ${t.name.split(':', 1)[0] === 'archive' ? t.name.split(':', 2)[1] : t.name}`
: `#${t.name}`;
chip.innerHTML = `${label} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
chip.innerHTML = `${icon} ${displayName} <button class="x" data-name="${t.name}" title="Remove">×</button>`;
tagList.appendChild(chip);
});
}
@@ -84,12 +103,129 @@ document.addEventListener('DOMContentLoaded', () => {
return false;
}
// ---------------------------
// Autocomplete helpers
// ---------------------------
function hideAutocomplete() {
if (tagAutocomplete) {
tagAutocomplete.classList.remove('active');
tagAutocomplete.innerHTML = '';
}
autocompleteItems = [];
autocompleteSelectedIndex = -1;
}
function renderAutocomplete(tags) {
if (!tagAutocomplete) return;
autocompleteItems = tags;
autocompleteSelectedIndex = -1;
if (tags.length === 0) {
tagAutocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
tagAutocomplete.classList.add('active');
return;
}
tagAutocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind);
const displayName = getTagDisplayName(t.name);
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${t.name}">
<span>${icon} ${displayName}</span>
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).join('');
tagAutocomplete.classList.add('active');
}
function selectAutocompleteItem(index) {
const items = tagAutocomplete?.querySelectorAll('.tag-autocomplete-item');
if (!items) return;
items.forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
autocompleteSelectedIndex = index;
}
async function fetchAutocomplete(query) {
try {
const url = query
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8`
: `/api/tags/search?limit=8`;
const r = await fetch(url);
const j = await r.json();
renderAutocomplete(j.tags || []);
} catch {
hideAutocomplete();
}
}
if (tagInput) {
// Show autocomplete on focus
tagInput.addEventListener('focus', () => {
const val = tagInput.value.trim();
fetchAutocomplete(val);
});
// Hide autocomplete on blur (with delay for click)
tagInput.addEventListener('blur', () => {
setTimeout(hideAutocomplete, 200);
});
// Search as user types
tagInput.addEventListener('input', () => {
clearTimeout(autocompleteDebounce);
autocompleteDebounce = setTimeout(() => {
fetchAutocomplete(tagInput.value.trim());
}, 150);
});
// Keyboard navigation
tagInput.addEventListener('keydown', (e) => {
if (!tagAutocomplete?.classList.contains('active')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
selectAutocompleteItem(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
selectAutocompleteItem(prevIndex);
} else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
e.preventDefault();
const selected = autocompleteItems[autocompleteSelectedIndex];
if (selected) {
tagInput.value = selected.name;
hideAutocomplete();
tagForm.dispatchEvent(new Event('submit'));
}
} else if (e.key === 'Escape') {
hideAutocomplete();
}
});
}
// Click on autocomplete item
if (tagAutocomplete) {
tagAutocomplete.addEventListener('click', (e) => {
const item = e.target.closest('.tag-autocomplete-item');
if (!item) return;
const name = item.dataset.name;
if (tagInput && name) {
tagInput.value = name;
hideAutocomplete();
tagForm.dispatchEvent(new Event('submit'));
}
});
}
if (tagForm) {
tagForm.addEventListener('submit', async (e) => {
e.preventDefault();
const id = getEditorImageId();
const name = (tagInput.value || '').trim();
if (!id || !name) return;
hideAutocomplete();
await addTag(id, name);
tagInput.value = '';
});
@@ -306,8 +442,23 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.key === 'Escape') closeModal();
});
images.forEach((img, index) => {
img.addEventListener('click', () => openModal(index));
// Use event delegation on the document for .img-clickable clicks
// This allows dynamically added elements (e.g., from shuffle) to work
document.addEventListener('click', (e) => {
const clickable = e.target.closest('.img-clickable');
if (!clickable) return;
// Re-query images to get current state (may have changed from shuffle)
images = Array.from(document.querySelectorAll('.img-clickable'));
const index = images.indexOf(clickable);
if (index !== -1) {
openModal(index);
}
});
// Listen for showcase updates to refresh the images array
document.addEventListener('showcaseUpdated', () => {
images = Array.from(document.querySelectorAll('.img-clickable'));
});
window.addEventListener('popstate', () => {
+283
View File
@@ -0,0 +1,283 @@
// /app/static/js/showcase.js
// JS-managed masonry with column distribution for infinite scroll
(function() {
const container = document.getElementById('showcaseGrid');
if (!container) return;
// Configuration
const DESKTOP_COLUMNS = 4;
const MOBILE_COLUMNS = 2;
const MOBILE_BREAKPOINT = 768;
const SCROLL_THRESHOLD = 1500; // Load more when 1500px from bottom (about 2-3 rows ahead)
const LOAD_BATCH_SIZE = 12;
// State
let columns = [];
let columnHeights = [];
let isLoading = false;
let currentColumnCount = 0;
// Initialize
function init() {
setupColumns();
loadInitialImages();
setupEventListeners();
}
function getColumnCount() {
return window.innerWidth < MOBILE_BREAKPOINT ? MOBILE_COLUMNS : DESKTOP_COLUMNS;
}
function setupColumns() {
const count = getColumnCount();
if (count === currentColumnCount && columns.length > 0) return;
currentColumnCount = count;
container.innerHTML = '';
columns = [];
columnHeights = [];
for (let i = 0; i < count; i++) {
const col = document.createElement('div');
col.className = 'masonry-column';
col.dataset.column = i;
container.appendChild(col);
columns.push(col);
columnHeights.push(0);
}
}
function loadInitialImages() {
const dataScript = document.getElementById('initialImages');
if (!dataScript) return;
try {
const images = JSON.parse(dataScript.textContent);
distributeImages(images, true); // Use lazy loading for initial images (some may be below fold)
} catch (e) {
console.error('Failed to parse initial images:', e);
}
}
function setupEventListeners() {
// Keyboard shuffle
document.addEventListener('keydown', (e) => {
const modal = document.getElementById('imageModal');
if (modal && modal.classList.contains('active')) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'r' || e.key === 'R' || e.key === ' ') {
e.preventDefault();
shuffle();
}
});
// Infinite scroll
let scrollTimeout = null;
window.addEventListener('scroll', () => {
if (scrollTimeout) return;
scrollTimeout = setTimeout(() => {
scrollTimeout = null;
checkScrollPosition();
}, 100);
});
// Window resize - redistribute if column count changes
let resizeTimeout = null;
window.addEventListener('resize', () => {
if (resizeTimeout) clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const newCount = getColumnCount();
if (newCount !== currentColumnCount) {
redistributeAllImages();
}
}, 200);
});
}
function checkScrollPosition() {
const scrollBottom = window.innerHeight + window.scrollY;
const docHeight = document.documentElement.scrollHeight;
if (docHeight - scrollBottom < SCROLL_THRESHOLD) {
loadMore();
}
}
function getShortestColumnIndex() {
let minHeight = Infinity;
let minIndex = 0;
for (let i = 0; i < columnHeights.length; i++) {
if (columnHeights[i] < minHeight) {
minHeight = columnHeights[i];
minIndex = i;
}
}
return minIndex;
}
function createImageElement(img, useLazyLoading = false) {
const item = document.createElement('div');
item.className = 'masonry-item img-clickable';
item.dataset.id = img.id;
item.dataset.full = img.full_url;
item.dataset.type = img.is_video ? 'video' : 'image';
item.dataset.filename = img.filename;
const imgEl = document.createElement('img');
imgEl.src = img.thumb_url;
imgEl.alt = img.filename;
if (useLazyLoading) {
imgEl.loading = 'lazy';
}
item.appendChild(imgEl);
if (img.tags && img.tags.length > 0) {
const overlay = document.createElement('div');
overlay.className = 'tag-overlay';
img.tags.forEach(t => {
const chip = document.createElement('a');
chip.className = 'tag-chip';
chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
chip.title = `Filter by ${t.name}`;
chip.onclick = (e) => e.stopPropagation();
chip.innerHTML = formatTag(t);
overlay.appendChild(chip);
});
item.appendChild(overlay);
}
if (img.is_video) {
const play = document.createElement('div');
play.className = 'play-overlay';
play.textContent = '▶';
item.appendChild(play);
}
return item;
}
function distributeImages(images, useLazyLoading = false) {
images.forEach(img => {
const colIndex = getShortestColumnIndex();
const item = createImageElement(img, useLazyLoading);
columns[colIndex].appendChild(item);
// Update height estimate (will be corrected after image loads)
columnHeights[colIndex] += 300;
// Correct height after image loads
const imgEl = item.querySelector('img');
imgEl.onload = () => {
updateColumnHeights();
};
});
document.dispatchEvent(new CustomEvent('showcaseUpdated'));
}
function updateColumnHeights() {
columns.forEach((col, i) => {
columnHeights[i] = col.offsetHeight;
});
}
function redistributeAllImages() {
// Collect all current items
const allItems = [];
columns.forEach(col => {
Array.from(col.children).forEach(item => {
allItems.push(item);
});
});
// Reset columns
setupColumns();
// Re-add items maintaining order
allItems.forEach(element => {
const colIndex = getShortestColumnIndex();
columns[colIndex].appendChild(element);
columnHeights[colIndex] += element.offsetHeight || 300;
});
document.dispatchEvent(new CustomEvent('showcaseUpdated'));
}
async function shuffle() {
if (isLoading) return;
isLoading = true;
container.classList.add('loading');
try {
const response = await fetch(`/api/random-images?count=20`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
// Clear everything
columns.forEach(col => col.innerHTML = '');
columnHeights = columnHeights.map(() => 0);
distributeImages(data.images);
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (err) {
console.error('Shuffle error:', err);
} finally {
container.classList.remove('loading');
isLoading = false;
}
}
async function loadMore() {
if (isLoading) return;
isLoading = true;
try {
const response = await fetch(`/api/random-images?count=${LOAD_BATCH_SIZE}`);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
if (data.images.length === 0) {
return;
}
distributeImages(data.images);
} catch (err) {
console.error('Load more error:', err);
} finally {
isLoading = false;
}
}
function formatTag(tag) {
const escape = (s) => {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
};
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
rating: '⚠️'
};
const icon = icons[tag.kind];
if (icon) {
const label = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
return `${icon} ${escape(label)}`;
} else {
return `#${escape(tag.name)}`;
}
}
// Start
init();
})();
+141
View File
@@ -0,0 +1,141 @@
// /app/static/js/tag-editor.js
// Tag management UI for the tags list page
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('tagEditModal');
const closeBtn = document.getElementById('tagEditClose');
const form = document.getElementById('tagEditForm');
const deleteBtn = document.getElementById('tagDeleteBtn');
const tagIdInput = document.getElementById('editTagId');
const nameInput = document.getElementById('editTagName');
const kindSelect = document.getElementById('editTagKind');
let currentTagCard = null;
// Open modal when edit button clicked
document.querySelectorAll('.tag-edit-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const tagId = btn.dataset.tagId;
currentTagCard = btn.closest('.tag-card');
// Fetch tag details
try {
const res = await fetch(`/api/tag/${tagId}`);
const data = await res.json();
if (data.ok) {
tagIdInput.value = data.tag.id;
nameInput.value = data.tag.display_name;
kindSelect.value = data.tag.kind || 'user';
modal.classList.add('active');
}
} catch (err) {
console.error('Failed to load tag:', err);
}
});
});
// Close modal
function closeModal() {
modal.classList.remove('active');
currentTagCard = null;
}
closeBtn?.addEventListener('click', closeModal);
modal?.addEventListener('click', (e) => {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal?.classList.contains('active')) {
closeModal();
}
});
// Handle form submission
async function submitUpdate(forceMerge = false) {
const tagId = tagIdInput.value;
const formData = new FormData();
formData.append('name', nameInput.value);
formData.append('kind', kindSelect.value);
if (forceMerge) {
formData.append('merge', 'true');
}
try {
const res = await fetch(`/api/tag/${tagId}/update`, {
method: 'POST',
body: formData
});
const data = await res.json();
if (data.ok) {
if (data.merged) {
// Tag was merged
alert(`Tags merged successfully! ${data.merged_count} images were updated.`);
}
// Reload page to show updated tag
window.location.reload();
} else if (data.can_merge) {
// Conflict - ask user if they want to merge
const targetName = data.target_tag.name;
const confirmMerge = confirm(
`A tag "${targetName}" already exists.\n\n` +
`Do you want to merge these tags? This will:\n` +
`• Move all images from the current tag to "${targetName}"\n` +
`• Delete the current tag\n\n` +
`This action cannot be undone.`
);
if (confirmMerge) {
await submitUpdate(true);
}
} else {
alert(data.error || 'Failed to update tag');
}
} catch (err) {
console.error('Failed to update tag:', err);
alert('Failed to update tag');
}
}
form?.addEventListener('submit', async (e) => {
e.preventDefault();
await submitUpdate(false);
});
// Handle delete
deleteBtn?.addEventListener('click', async () => {
const tagId = tagIdInput.value;
const tagName = nameInput.value;
if (!confirm(`Are you sure you want to delete the tag "${tagName}"? This will remove it from all images.`)) {
return;
}
try {
const res = await fetch(`/api/tag/${tagId}/delete`, {
method: 'POST'
});
const data = await res.json();
if (data.ok) {
// Remove the card from DOM and close modal
if (currentTagCard) {
currentTagCard.remove();
}
closeModal();
} else {
alert(data.error || 'Failed to delete tag');
}
} catch (err) {
console.error('Failed to delete tag:', err);
alert('Failed to delete tag');
}
});
});
+731 -184
View File
File diff suppressed because it is too large Load Diff