major updates to theming, creation of showcase view and polish of existing systems including tagging editting.
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user