03184e7f1c
Replace the floating autocomplete popup with a reserved ~5-row inline area below the add-tag input. It never covers provenance or suggestion chips — they stay visible side-by-side with the autocomplete list while the user types. The area scrolls when more matches are loaded, and is prefilled with top tags on every image load so it's never empty. The base .tag-autocomplete class (still used by bulk-select and the fandom picker) keeps its floating behavior; the modal opts in to the new inline layout via an added .tag-autocomplete-inline modifier. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1066 lines
38 KiB
JavaScript
1066 lines
38 KiB
JavaScript
// /app/static/js/view-modal.js
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
const modal = document.getElementById('imageModal');
|
||
const modalPrev = document.getElementById('modalPrev');
|
||
const modalNext = document.getElementById('modalNext');
|
||
const modalClose = document.getElementById('modalClose');
|
||
const modalImageWrapper = document.querySelector('.modal-image-wrapper');
|
||
|
||
// NEW: tag editor elements
|
||
const tagEditor = document.getElementById('modalTagEditor');
|
||
const tagList = document.getElementById('modalTagList');
|
||
const tagForm = document.getElementById('modalTagForm');
|
||
const tagInput = tagForm ? tagForm.querySelector('input[name="name"]') : null;
|
||
const tagAutocomplete = document.getElementById('tagAutocomplete');
|
||
const tagActionFeedback = document.getElementById('tagActionFeedback');
|
||
const provenanceSection = document.getElementById('modalProvenanceSection');
|
||
const provenanceList = document.getElementById('modalProvenanceList');
|
||
const suggestionsSection = document.getElementById('modalSuggestionsSection');
|
||
const suggestionsList = document.getElementById('modalSuggestionsList');
|
||
const suggestionsRefresh = document.getElementById('suggestionsRefreshBtn');
|
||
|
||
// Series info elements (when image IS in a series)
|
||
const seriesInfoEl = document.getElementById('modalSeriesInfo');
|
||
const seriesNameEl = document.getElementById('modalSeriesName');
|
||
const pageNumberInput = document.getElementById('modalPageNumber');
|
||
const updatePageBtn = document.getElementById('updatePageBtn');
|
||
const pageUpdateStatus = document.getElementById('pageUpdateStatus');
|
||
let currentSeriesPageId = null;
|
||
|
||
// Add to series elements (when image is NOT in a series)
|
||
const addToSeriesEl = document.getElementById('modalAddToSeries');
|
||
const seriesSelect = document.getElementById('seriesSelect');
|
||
const addSeriesPageNumber = document.getElementById('addSeriesPageNumber');
|
||
const addToSeriesBtn = document.getElementById('addToSeriesBtn');
|
||
const addToSeriesStatus = document.getElementById('addToSeriesStatus');
|
||
const addToSeriesToggle = document.getElementById('addToSeriesToggle');
|
||
const addToSeriesBody = document.getElementById('addToSeriesBody');
|
||
let seriesTagsLoaded = false;
|
||
|
||
// In-context feedback: show a brief message next to an element, auto-fades
|
||
function showFeedback(el, message, isError = false) {
|
||
if (!el) return;
|
||
el.textContent = message;
|
||
el.style.color = isError ? 'var(--flash-danger)' : 'var(--flash-success)';
|
||
el.classList.add('visible');
|
||
clearTimeout(el._feedbackTimeout);
|
||
el._feedbackTimeout = setTimeout(() => {
|
||
el.classList.remove('visible');
|
||
}, 1500);
|
||
}
|
||
|
||
// Autocomplete state
|
||
let autocompleteItems = [];
|
||
let autocompleteSelectedIndex = -1;
|
||
let autocompleteDebounce = null;
|
||
|
||
let images = Array.from(document.querySelectorAll('.img-clickable'));
|
||
let currentIndex = -1;
|
||
let isZoomed = false;
|
||
let isDragging = false;
|
||
let startX, startY, scrollLeft, scrollTop;
|
||
let dragStartX = 0;
|
||
let dragStartY = 0;
|
||
let dragMoved = false;
|
||
|
||
// Legacy pagination inputs (for non-infinite-scroll pages)
|
||
const hasNextPageInput = document.getElementById('hasNextPage');
|
||
const nextPageUrlInput = document.getElementById('nextPageUrl');
|
||
const hasPrevPageInput = document.getElementById('hasPrevPage');
|
||
const prevPageUrlInput = document.getElementById('prevPageUrl');
|
||
|
||
// Check if we're in infinite scroll mode
|
||
// Infinite scroll mode is when galleryState exists (set by gallery.html template)
|
||
const isInfiniteScrollMode = typeof window.galleryState !== 'undefined';
|
||
|
||
// ---------------------------
|
||
// Tag editor helpers
|
||
// ---------------------------
|
||
// Use shared TagUtils module
|
||
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
|
||
|
||
function setEditorImageId(id) {
|
||
if (tagEditor) tagEditor.dataset.imageId = id || '';
|
||
}
|
||
function getEditorImageId() {
|
||
return tagEditor ? tagEditor.dataset.imageId : '';
|
||
}
|
||
|
||
function getProvenanceDisplayName(name, kind) {
|
||
// Artist tag names are "artist:name" — strip the prefix.
|
||
if (kind === 'artist' && name.startsWith('artist:')) return name.slice(7);
|
||
// Post tag names are "post:platform:artist:id" — strip "post:" only; the rest
|
||
// is a placeholder replaced by PostMetadata.title or post_id once it loads.
|
||
if (kind === 'post' && name.startsWith('post:')) return name.slice(5);
|
||
return name;
|
||
}
|
||
|
||
function renderTags(tags) {
|
||
if (!tagList) return;
|
||
tagList.innerHTML = '';
|
||
(tags || []).forEach(t => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'tag-chip';
|
||
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);
|
||
});
|
||
}
|
||
|
||
const SUGGESTION_CATEGORY_ORDER = ['character', 'copyright', 'general'];
|
||
const SUGGESTION_CATEGORY_LABEL = {
|
||
character: 'Characters',
|
||
copyright: 'Fandoms',
|
||
general: 'General',
|
||
};
|
||
|
||
function clearSuggestions() {
|
||
if (suggestionsList) suggestionsList.innerHTML = '';
|
||
if (suggestionsSection) suggestionsSection.style.display = 'none';
|
||
}
|
||
|
||
function buildSuggestionChip(suggestion, imageId) {
|
||
const chip = document.createElement('div');
|
||
chip.className = 'suggestion-chip';
|
||
chip.dataset.tagName = suggestion.name;
|
||
chip.dataset.source = suggestion.source;
|
||
chip.dataset.category = suggestion.category;
|
||
chip.dataset.confidence = String(suggestion.confidence);
|
||
chip.title = `${suggestion.name} (${suggestion.source})`;
|
||
|
||
const pct = Math.round(suggestion.confidence * 100);
|
||
chip.innerHTML = `
|
||
<button type="button" class="sugg-btn sugg-accept" aria-label="Accept">✓</button>
|
||
<span class="sugg-body">
|
||
<span class="sugg-label">${escapeHtml(suggestion.name)}</span>
|
||
<span class="sugg-confidence">${pct}%</span>
|
||
</span>
|
||
<button type="button" class="sugg-btn sugg-reject" aria-label="Reject">✕</button>
|
||
`;
|
||
|
||
const acceptBtn = chip.querySelector('.sugg-accept');
|
||
const rejectBtn = chip.querySelector('.sugg-reject');
|
||
if (acceptBtn) {
|
||
acceptBtn.addEventListener('click', () => acceptSuggestion(chip, imageId));
|
||
}
|
||
if (rejectBtn) {
|
||
rejectBtn.addEventListener('click', () => rejectSuggestion(chip, imageId));
|
||
}
|
||
return chip;
|
||
}
|
||
|
||
function renderSuggestions(grouped, imageId) {
|
||
if (!suggestionsSection || !suggestionsList) return;
|
||
suggestionsList.innerHTML = '';
|
||
let total = 0;
|
||
for (const cat of SUGGESTION_CATEGORY_ORDER) {
|
||
const items = (grouped && grouped[cat]) || [];
|
||
if (items.length === 0) continue;
|
||
total += items.length;
|
||
|
||
const group = document.createElement('div');
|
||
group.className = 'suggestions-category';
|
||
group.dataset.category = cat;
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'suggestions-category-label';
|
||
label.textContent = SUGGESTION_CATEGORY_LABEL[cat] || cat;
|
||
group.appendChild(label);
|
||
|
||
const chips = document.createElement('div');
|
||
chips.className = 'suggestions-chips';
|
||
for (const item of items) {
|
||
chips.appendChild(buildSuggestionChip(item, imageId));
|
||
}
|
||
group.appendChild(chips);
|
||
suggestionsList.appendChild(group);
|
||
}
|
||
suggestionsSection.style.display = total > 0 ? '' : 'none';
|
||
}
|
||
|
||
async function loadSuggestions(imageId) {
|
||
if (!imageId || !suggestionsSection) { clearSuggestions(); return; }
|
||
try {
|
||
const r = await fetch(`/image/${imageId}/suggestions`);
|
||
const j = await r.json();
|
||
if (j.ok) renderSuggestions(j.suggestions || {}, imageId);
|
||
else clearSuggestions();
|
||
} catch {
|
||
clearSuggestions();
|
||
}
|
||
}
|
||
|
||
function animateChipOut(chip) {
|
||
chip.classList.add('leaving');
|
||
setTimeout(() => { chip.remove(); }, 200);
|
||
}
|
||
|
||
async function acceptSuggestion(chip, imageId) {
|
||
if (chip.dataset.disabled === 'true') return;
|
||
chip.dataset.disabled = 'true';
|
||
chip.style.pointerEvents = 'none';
|
||
try {
|
||
const r = await fetch(`/image/${imageId}/suggestions/accept`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tag_name: chip.dataset.tagName,
|
||
source: chip.dataset.source,
|
||
category: chip.dataset.category,
|
||
confidence: Number(chip.dataset.confidence) || 0,
|
||
}),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) {
|
||
chip.dataset.disabled = 'false';
|
||
chip.style.pointerEvents = '';
|
||
if (tagActionFeedback) {
|
||
tagActionFeedback.textContent = `Couldn't accept: ${j.error || 'error'}`;
|
||
}
|
||
return;
|
||
}
|
||
if (typeof loadTags === 'function') await loadTags(imageId);
|
||
if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.name}`;
|
||
if (typeof window.refreshItemTags === 'function') window.refreshItemTags(imageId);
|
||
animateChipOut(chip);
|
||
} catch {
|
||
chip.dataset.disabled = 'false';
|
||
chip.style.pointerEvents = '';
|
||
}
|
||
}
|
||
|
||
async function rejectSuggestion(chip, imageId) {
|
||
chip.style.pointerEvents = 'none';
|
||
try {
|
||
await fetch(`/image/${imageId}/suggestions/reject`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
tag_name: chip.dataset.tagName,
|
||
source: chip.dataset.source,
|
||
confidence: Number(chip.dataset.confidence) || 0,
|
||
}),
|
||
});
|
||
animateChipOut(chip);
|
||
if (tagActionFeedback) tagActionFeedback.textContent = `Rejected ${chip.dataset.tagName}`;
|
||
} catch {
|
||
chip.style.pointerEvents = '';
|
||
}
|
||
}
|
||
|
||
function renderProvenance(tags) {
|
||
if (!provenanceSection || !provenanceList) return;
|
||
provenanceList.innerHTML = '';
|
||
if (!tags || tags.length === 0) {
|
||
provenanceSection.style.display = 'none';
|
||
return;
|
||
}
|
||
tags.forEach(t => {
|
||
const chip = document.createElement('a');
|
||
chip.className = 'provenance-chip';
|
||
chip.href = `/gallery?tag=${encodeURIComponent(t.name)}`;
|
||
chip.title = t.name;
|
||
chip.dataset.tagName = t.name;
|
||
chip.dataset.kind = t.kind;
|
||
const icon = getTagIcon(t.kind);
|
||
const placeholder = escapeHtml(getProvenanceDisplayName(t.name, t.kind));
|
||
chip.innerHTML = `${icon} <span class="provenance-label">${placeholder}</span> <span class="provenance-arrow">↗</span>`;
|
||
provenanceList.appendChild(chip);
|
||
if (t.kind === 'post') upgradeProvenanceChip(chip, t.name);
|
||
});
|
||
provenanceSection.style.display = '';
|
||
}
|
||
|
||
async function upgradeProvenanceChip(chip, tagName) {
|
||
try {
|
||
const r = await fetch(`/api/post-metadata/by-tag-name/${encodeURIComponent(tagName)}`);
|
||
const j = await r.json();
|
||
if (!j.ok || !j.metadata) return;
|
||
const pm = j.metadata;
|
||
if (pm.platform) chip.dataset.platform = pm.platform;
|
||
const label = pm.title || pm.post_id;
|
||
if (label) {
|
||
const labelEl = chip.querySelector('.provenance-label');
|
||
if (labelEl) labelEl.textContent = label;
|
||
}
|
||
} catch {
|
||
// Fetch failed — chip keeps its placeholder label and neutral accent.
|
||
}
|
||
}
|
||
async function loadTags(imageId) {
|
||
if (!imageId) { renderTags([]); renderProvenance([]); return; }
|
||
try {
|
||
const r = await fetch(`/image/${imageId}/tags`);
|
||
const j = await r.json();
|
||
if (j.ok) {
|
||
const all = j.tags || [];
|
||
// Provenance: artist first, then post — non-editable, system-tagged origin info.
|
||
const provenanceTags = [
|
||
...all.filter(t => t.kind === 'artist'),
|
||
...all.filter(t => t.kind === 'post'),
|
||
];
|
||
// Editable list excludes provenance kinds and hidden kinds (source, archive).
|
||
const hiddenFromModal = new Set(['artist', 'post', 'source', 'archive']);
|
||
const editableTags = all.filter(t => !hiddenFromModal.has(t.kind));
|
||
renderTags(editableTags);
|
||
renderProvenance(provenanceTags);
|
||
loadSuggestions(imageId);
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// ---------------------------
|
||
// Gallery item tag refresh (fallback if bulk-select.js not loaded)
|
||
// ---------------------------
|
||
async function refreshItemTagsFallback(imageId) {
|
||
// Only the gallery page renders tag overlays on thumbnails. Showcase thumbnails
|
||
// use a tag-count badge instead and must not have .tag-overlay forced onto them.
|
||
const item = document.querySelector(`.gallery-item[data-id="${imageId}"]`);
|
||
if (!item) return;
|
||
|
||
try {
|
||
const r = await fetch(`/image/${imageId}/tags`);
|
||
const data = await r.json();
|
||
if (!data.ok) return;
|
||
|
||
const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive' && t.kind !== 'source');
|
||
let overlay = item.querySelector('.tag-overlay');
|
||
|
||
if (visibleTags.length === 0) {
|
||
if (overlay) overlay.remove();
|
||
return;
|
||
}
|
||
|
||
if (!overlay) {
|
||
overlay = document.createElement('div');
|
||
overlay.className = 'tag-overlay';
|
||
item.appendChild(overlay);
|
||
}
|
||
|
||
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).join('');
|
||
} catch (e) {
|
||
console.error('Failed to refresh tags for image', imageId, e);
|
||
}
|
||
}
|
||
|
||
// Register fallback if bulk-select.js hasn't registered it
|
||
if (!window.refreshItemTags) {
|
||
window.refreshItemTags = refreshItemTagsFallback;
|
||
}
|
||
|
||
// ---------------------------
|
||
// Series info helpers
|
||
// ---------------------------
|
||
function hideSeriesInfo() {
|
||
if (seriesInfoEl) seriesInfoEl.style.display = 'none';
|
||
currentSeriesPageId = null;
|
||
if (pageUpdateStatus) { pageUpdateStatus.textContent = ''; pageUpdateStatus.classList.remove('visible'); }
|
||
}
|
||
|
||
function hideAddToSeries() {
|
||
if (addToSeriesEl) addToSeriesEl.style.display = 'none';
|
||
if (addToSeriesStatus) { addToSeriesStatus.textContent = ''; addToSeriesStatus.classList.remove('visible'); }
|
||
// Reset toggle state
|
||
if (addToSeriesToggle) addToSeriesToggle.classList.remove('open');
|
||
if (addToSeriesBody) addToSeriesBody.style.display = 'none';
|
||
}
|
||
|
||
async function loadSeriesTags() {
|
||
if (!seriesSelect) return;
|
||
if (seriesTagsLoaded) return Promise.resolve();
|
||
try {
|
||
// Fetch all series tags using the kind filter
|
||
const r = await fetch('/api/tags/search?kind=series&limit=100');
|
||
const j = await r.json();
|
||
const seriesTags = j.tags || [];
|
||
|
||
// Clear existing options except the first placeholder
|
||
seriesSelect.innerHTML = '<option value="">Select series...</option>';
|
||
|
||
seriesTags.forEach(tag => {
|
||
const opt = document.createElement('option');
|
||
opt.value = tag.id;
|
||
// Display name without prefix
|
||
opt.textContent = tag.name.includes(':') ? tag.name.split(':', 2)[1] : tag.name;
|
||
seriesSelect.appendChild(opt);
|
||
});
|
||
|
||
seriesTagsLoaded = true;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
function showAddToSeries(seriesPage = null) {
|
||
if (!addToSeriesEl) return;
|
||
// Store pending pre-fill data for when the toggle is opened
|
||
addToSeriesEl._pendingSeriesPage = seriesPage || null;
|
||
if (addToSeriesStatus) { addToSeriesStatus.textContent = ''; addToSeriesStatus.classList.remove('visible'); }
|
||
// Show the outer container; inner body stays hidden until toggle click
|
||
addToSeriesEl.style.display = 'block';
|
||
}
|
||
|
||
async function handleAddToSeries() {
|
||
const imageId = getEditorImageId();
|
||
const seriesTagId = seriesSelect?.value;
|
||
const pageNumber = parseInt(addSeriesPageNumber?.value);
|
||
|
||
if (!imageId || !seriesTagId || isNaN(pageNumber) || pageNumber < 1) return;
|
||
|
||
const isUpdate = addToSeriesBtn?.textContent === 'Update';
|
||
if (addToSeriesBtn) addToSeriesBtn.disabled = true;
|
||
showFeedback(addToSeriesStatus, isUpdate ? 'Updating…' : 'Adding…');
|
||
|
||
try {
|
||
const r = await fetch(`/api/image/${imageId}/add-to-series`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ series_tag_id: parseInt(seriesTagId), page_number: pageNumber })
|
||
});
|
||
const j = await r.json();
|
||
|
||
if (j.ok) {
|
||
showFeedback(addToSeriesStatus, 'Saved');
|
||
// Fully reload series info to show updated management interface
|
||
await loadSeriesInfo(imageId);
|
||
} else {
|
||
showFeedback(addToSeriesStatus, j.error || 'Error', true);
|
||
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
|
||
}
|
||
} catch (e) {
|
||
showFeedback(addToSeriesStatus, 'Error', true);
|
||
if (addToSeriesBtn) addToSeriesBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function showSeriesInfo(seriesPage) {
|
||
if (!seriesInfoEl || !seriesPage) return;
|
||
currentSeriesPageId = seriesPage.id;
|
||
|
||
// Display series name (strip prefix)
|
||
const displayName = seriesPage.series_name.includes(':')
|
||
? seriesPage.series_name.split(':', 2)[1]
|
||
: seriesPage.series_name;
|
||
if (seriesNameEl) {
|
||
seriesNameEl.textContent = displayName;
|
||
seriesNameEl.href = `/gallery?tag=${encodeURIComponent(seriesPage.series_name)}`;
|
||
}
|
||
|
||
// Display page number
|
||
if (pageNumberInput) {
|
||
pageNumberInput.value = seriesPage.page_number;
|
||
}
|
||
|
||
if (pageUpdateStatus) { pageUpdateStatus.textContent = ''; pageUpdateStatus.classList.remove('visible'); }
|
||
seriesInfoEl.style.display = 'block';
|
||
}
|
||
|
||
async function loadSeriesInfo(imageId) {
|
||
hideSeriesInfo();
|
||
hideAddToSeries();
|
||
if (!imageId) return;
|
||
try {
|
||
const r = await fetch(`/api/image/${imageId}/series-info`);
|
||
const j = await r.json();
|
||
if (j.ok && j.in_series) {
|
||
// Show series info (with link) and pre-fill the dropdown
|
||
showSeriesInfo(j.series_page);
|
||
showAddToSeries(j.series_page);
|
||
} else {
|
||
// Show add-to-series option when not in a series
|
||
// Auto-select if image has exactly one series tag
|
||
const seriesTags = j.series_tags || [];
|
||
if (seriesTags.length === 1) {
|
||
showAddToSeries({ series_tag_id: seriesTags[0].id, page_number: 1 });
|
||
} else {
|
||
showAddToSeries();
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
async function updatePageNumber() {
|
||
if (!currentSeriesPageId || !pageNumberInput) return;
|
||
const newPageNumber = parseInt(pageNumberInput.value);
|
||
if (isNaN(newPageNumber) || newPageNumber < 1) {
|
||
showFeedback(pageUpdateStatus, 'Invalid', true);
|
||
return;
|
||
}
|
||
|
||
if (updatePageBtn) updatePageBtn.disabled = true;
|
||
showFeedback(pageUpdateStatus, 'Saving…');
|
||
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('page_number', newPageNumber);
|
||
const r = await fetch(`/api/series/page/${currentSeriesPageId}/update`, {
|
||
method: 'POST',
|
||
body: fd
|
||
});
|
||
const j = await r.json();
|
||
if (j.ok) {
|
||
showFeedback(pageUpdateStatus, 'Saved');
|
||
} else {
|
||
showFeedback(pageUpdateStatus, j.error || 'Error', true);
|
||
}
|
||
} catch (e) {
|
||
showFeedback(pageUpdateStatus, 'Error', true);
|
||
} finally {
|
||
if (updatePageBtn) updatePageBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
// Wire up the save button
|
||
if (updatePageBtn) {
|
||
updatePageBtn.addEventListener('click', updatePageNumber);
|
||
}
|
||
|
||
// Allow Enter key in page number input to save
|
||
if (pageNumberInput) {
|
||
pageNumberInput.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
updatePageNumber();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Enable/disable add button based on series selection
|
||
if (seriesSelect) {
|
||
seriesSelect.addEventListener('change', () => {
|
||
if (addToSeriesBtn) {
|
||
addToSeriesBtn.disabled = !seriesSelect.value;
|
||
// Update button text: "Update" if image is in a series, "Add" if not
|
||
if (seriesSelect.value && currentSeriesPageId) {
|
||
addToSeriesBtn.textContent = 'Update';
|
||
} else if (seriesSelect.value) {
|
||
addToSeriesBtn.textContent = 'Add';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add to series button click
|
||
if (addToSeriesBtn) {
|
||
addToSeriesBtn.addEventListener('click', handleAddToSeries);
|
||
}
|
||
|
||
// Allow Enter key in add-to-series page number input
|
||
if (addSeriesPageNumber) {
|
||
addSeriesPageNumber.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter' && seriesSelect?.value) {
|
||
e.preventDefault();
|
||
handleAddToSeries();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Toggle "Add to Series" collapse
|
||
if (addToSeriesToggle) {
|
||
addToSeriesToggle.addEventListener('click', () => {
|
||
const isOpen = addToSeriesToggle.classList.toggle('open');
|
||
addToSeriesBody.style.display = isOpen ? 'block' : 'none';
|
||
// Load series options on first open, then apply any pending pre-fill
|
||
if (isOpen) {
|
||
const seriesPage = addToSeriesEl._pendingSeriesPage;
|
||
loadSeriesTags().then(() => {
|
||
if (seriesPage && seriesSelect) {
|
||
seriesSelect.value = seriesPage.series_tag_id;
|
||
if (addSeriesPageNumber) addSeriesPageNumber.value = seriesPage.page_number;
|
||
if (addToSeriesBtn) {
|
||
addToSeriesBtn.disabled = false;
|
||
addToSeriesBtn.textContent = 'Update';
|
||
}
|
||
} else {
|
||
if (seriesSelect) seriesSelect.value = '';
|
||
if (addSeriesPageNumber) addSeriesPageNumber.value = '1';
|
||
if (addToSeriesBtn) {
|
||
addToSeriesBtn.disabled = true;
|
||
addToSeriesBtn.textContent = 'Add';
|
||
}
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
async function addTag(imageId, name) {
|
||
const fd = new FormData();
|
||
fd.append('name', name);
|
||
const r = await fetch(`/image/${imageId}/tags/add`, { method: 'POST', body: fd });
|
||
const j = await r.json();
|
||
if (j.ok) {
|
||
await loadTags(imageId);
|
||
showFeedback(tagActionFeedback, 'Tag added');
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
async function removeTag(imageId, name) {
|
||
const fd = new FormData();
|
||
fd.append('name', name);
|
||
const r = await fetch(`/image/${imageId}/tags/remove`, { method: 'POST', body: fd });
|
||
const j = await r.json();
|
||
if (j.ok) {
|
||
await loadTags(imageId);
|
||
showFeedback(tagActionFeedback, 'Tag removed');
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// ---------------------------
|
||
// Autocomplete helpers
|
||
// ---------------------------
|
||
// The autocomplete area is an always-reserved block below the input
|
||
// (see .tag-autocomplete in style.css), so there's nothing to show/hide —
|
||
// the area is either populated with matches, the empty-state message, or a
|
||
// muted placeholder when there's no active query.
|
||
function clearAutocomplete(placeholder = '') {
|
||
if (tagAutocomplete) {
|
||
tagAutocomplete.innerHTML = placeholder
|
||
? `<div class="tag-autocomplete-empty">${escapeHtml(placeholder)}</div>`
|
||
: '';
|
||
}
|
||
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>';
|
||
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('');
|
||
}
|
||
|
||
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 {
|
||
// Exclude system-managed tag kinds from autocomplete (archive, post, source)
|
||
const excludeKinds = 'archive,post,source';
|
||
const url = query
|
||
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=20&exclude_kind=${excludeKinds}`
|
||
: `/api/tags/search?limit=20&exclude_kind=${excludeKinds}`;
|
||
const r = await fetch(url);
|
||
const j = await r.json();
|
||
renderAutocomplete(j.tags || []);
|
||
} catch {
|
||
clearAutocomplete();
|
||
}
|
||
}
|
||
|
||
// Shared function to submit a tag (avoids page refresh issues with form dispatch)
|
||
async function submitTag() {
|
||
const id = getEditorImageId();
|
||
const name = (tagInput?.value || '').trim();
|
||
if (!id || !name) return;
|
||
await addTag(id, name);
|
||
if (tagInput) tagInput.value = '';
|
||
// Reset the reserved area to the top-tags view so it's ready for the next add.
|
||
fetchAutocomplete('');
|
||
}
|
||
|
||
if (tagInput) {
|
||
// Refresh top tags on focus so the area reflects the latest input value.
|
||
tagInput.addEventListener('focus', () => {
|
||
fetchAutocomplete(tagInput.value.trim());
|
||
});
|
||
|
||
// Search as user types.
|
||
tagInput.addEventListener('input', () => {
|
||
clearTimeout(autocompleteDebounce);
|
||
autocompleteDebounce = setTimeout(() => {
|
||
fetchAutocomplete(tagInput.value.trim());
|
||
}, 150);
|
||
});
|
||
|
||
// Keyboard navigation — active whenever the reserved area has items.
|
||
tagInput.addEventListener('keydown', (e) => {
|
||
if (autocompleteItems.length === 0) 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;
|
||
submitTag();
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
selectAutocompleteItem(-1);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Click an autocomplete item to select and submit it. Use mousedown so the
|
||
// interaction still fires even though the input is losing focus.
|
||
if (tagAutocomplete) {
|
||
tagAutocomplete.addEventListener('mousedown', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
const item = e.target.closest('.tag-autocomplete-item');
|
||
if (!item) return;
|
||
const name = item.dataset.name;
|
||
if (tagInput && name) {
|
||
tagInput.value = name;
|
||
submitTag();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (tagForm) {
|
||
tagForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
await submitTag();
|
||
});
|
||
}
|
||
if (tagList) {
|
||
tagList.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('button.x');
|
||
if (!btn) return;
|
||
const id = getEditorImageId();
|
||
const name = btn.dataset.name;
|
||
if (!id || !name) return;
|
||
await removeTag(id, name);
|
||
});
|
||
}
|
||
|
||
// ---------------------------
|
||
// Existing viewer logic
|
||
// ---------------------------
|
||
function updateImage(index) {
|
||
const img = images[index];
|
||
const fileType = img.dataset.type || "image";
|
||
const imageId = img.dataset.id || "";
|
||
|
||
// clear wrapper
|
||
modalImageWrapper.innerHTML = "";
|
||
|
||
if (fileType === "video") {
|
||
const video = document.createElement("video");
|
||
video.src = img.dataset.full;
|
||
video.controls = true;
|
||
video.autoplay = true;
|
||
video.style.maxWidth = "100%";
|
||
video.style.maxHeight = "100%";
|
||
modalImageWrapper.appendChild(video);
|
||
} else {
|
||
const image = document.createElement("img");
|
||
image.src = img.dataset.full;
|
||
image.alt = "Full View";
|
||
image.setAttribute("draggable", "false");
|
||
image.id = "modalImage";
|
||
modalImageWrapper.appendChild(image);
|
||
}
|
||
|
||
currentIndex = index;
|
||
resetZoom();
|
||
|
||
// NEW: load tags for this image into the modal editor
|
||
setEditorImageId(imageId);
|
||
if (tagInput) tagInput.value = '';
|
||
loadTags(imageId);
|
||
loadSeriesInfo(imageId);
|
||
// Prefill the reserved autocomplete area with top tags so it's never empty.
|
||
fetchAutocomplete('');
|
||
}
|
||
|
||
function openModal(index) {
|
||
modal.classList.add('active');
|
||
updateImage(index);
|
||
// Auto-focus the tag input on desktop so users can start typing immediately.
|
||
// Skipped on mobile — the on-screen keyboard would shove the image out of
|
||
// view and focus feels unexpected on touch.
|
||
if (tagInput && window.innerWidth >= 768) {
|
||
tagInput.focus();
|
||
}
|
||
history.replaceState({ modalIndex: index }, '', window.location.href);
|
||
}
|
||
|
||
function closeModal() {
|
||
// Get the image ID before clearing so we can refresh its tags
|
||
const imageId = getEditorImageId();
|
||
|
||
modal.classList.remove('active');
|
||
modalImageWrapper.innerHTML = '';
|
||
currentIndex = -1;
|
||
resetZoom();
|
||
// clear tag UI
|
||
setEditorImageId('');
|
||
if (tagInput) tagInput.value = '';
|
||
renderTags([]);
|
||
renderProvenance([]);
|
||
clearSuggestions();
|
||
clearAutocomplete();
|
||
hideSeriesInfo();
|
||
hideAddToSeries();
|
||
history.replaceState({}, '', window.location.pathname + window.location.search);
|
||
|
||
// Refresh the gallery item's tags display (if function available from bulk-select.js)
|
||
if (imageId && window.refreshItemTags) {
|
||
window.refreshItemTags(imageId);
|
||
}
|
||
}
|
||
|
||
function showNext() {
|
||
if (currentIndex < images.length - 1) {
|
||
updateImage(currentIndex + 1);
|
||
} else if (isInfiniteScrollMode) {
|
||
// In infinite scroll mode, just stay at the last image
|
||
// User needs to scroll the gallery to load more
|
||
return;
|
||
} else if (hasNextPageInput?.value === "true") {
|
||
// Legacy pagination mode - fetch next page
|
||
fetch(nextPageUrlInput.value)
|
||
.then(res => res.text())
|
||
.then(html => {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
const newGallery = doc.querySelector('.gallery-grid');
|
||
const newImages = doc.querySelectorAll('.img-clickable');
|
||
|
||
if (newGallery && newImages.length > 0) {
|
||
const gallery = document.querySelector('.gallery-grid');
|
||
gallery.innerHTML = newGallery.innerHTML;
|
||
|
||
images = Array.from(document.querySelectorAll('.img-clickable'));
|
||
images.forEach((img, i) => {
|
||
img.addEventListener('click', () => openModal(i));
|
||
});
|
||
|
||
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
|
||
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
|
||
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
|
||
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
|
||
|
||
history.pushState({}, '', nextPageUrlInput.value);
|
||
openModal(0);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function showPrev() {
|
||
if (currentIndex > 0) {
|
||
updateImage(currentIndex - 1);
|
||
} else if (isInfiniteScrollMode) {
|
||
// In infinite scroll mode, just stay at the first image
|
||
return;
|
||
} else if (hasPrevPageInput?.value === "true") {
|
||
// Legacy pagination mode - fetch previous page
|
||
fetch(prevPageUrlInput.value)
|
||
.then(res => res.text())
|
||
.then(html => {
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
const newGallery = doc.querySelector('.gallery-grid');
|
||
const newImages = doc.querySelectorAll('.img-clickable');
|
||
|
||
if (newGallery && newImages.length > 0) {
|
||
const gallery = document.querySelector('.gallery-grid');
|
||
gallery.innerHTML = newGallery.innerHTML;
|
||
|
||
images = Array.from(document.querySelectorAll('.img-clickable'));
|
||
images.forEach((img, i) => {
|
||
img.addEventListener('click', () => openModal(i));
|
||
});
|
||
|
||
hasNextPageInput.value = doc.getElementById('hasNextPage')?.value || "false";
|
||
nextPageUrlInput.value = doc.getElementById('nextPageUrl')?.value || "";
|
||
hasPrevPageInput.value = doc.getElementById('hasPrevPage')?.value || "false";
|
||
prevPageUrlInput.value = doc.getElementById('prevPageUrl')?.value || "";
|
||
|
||
history.pushState({}, '', prevPageUrlInput.value);
|
||
openModal(images.length - 1);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
function toggleZoom() {
|
||
const isImage = modalImageWrapper.querySelector('img') !== null;
|
||
if (!isImage) return;
|
||
isZoomed = !isZoomed;
|
||
modalImageWrapper.classList.toggle('zoomed', isZoomed);
|
||
modalImageWrapper.style.cursor = isZoomed ? 'grab' : '';
|
||
if (!isZoomed) {
|
||
modalImageWrapper.scrollLeft = 0;
|
||
modalImageWrapper.scrollTop = 0;
|
||
}
|
||
}
|
||
|
||
function resetZoom() {
|
||
isZoomed = false;
|
||
modalImageWrapper.classList.remove('zoomed');
|
||
modalImageWrapper.scrollLeft = 0;
|
||
modalImageWrapper.scrollTop = 0;
|
||
modalImageWrapper.style.cursor = '';
|
||
}
|
||
|
||
modalImageWrapper.addEventListener('mousedown', (e) => {
|
||
const isImage = modalImageWrapper.querySelector('img') !== null;
|
||
if (!isZoomed || !isImage) return;
|
||
isDragging = true;
|
||
dragMoved = false;
|
||
modalImageWrapper.style.cursor = 'grabbing';
|
||
startX = e.pageX - modalImageWrapper.offsetLeft;
|
||
startY = e.pageY - modalImageWrapper.offsetTop;
|
||
scrollLeft = modalImageWrapper.scrollLeft;
|
||
scrollTop = modalImageWrapper.scrollTop;
|
||
dragStartX = e.pageX;
|
||
dragStartY = e.pageY;
|
||
});
|
||
|
||
modalImageWrapper.addEventListener('mouseleave', () => {
|
||
isDragging = false;
|
||
const isImage = modalImageWrapper.querySelector('img') !== null;
|
||
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
|
||
});
|
||
|
||
modalImageWrapper.addEventListener('mouseup', () => {
|
||
isDragging = false;
|
||
const isImage = modalImageWrapper.querySelector('img') !== null;
|
||
if (isZoomed && isImage) modalImageWrapper.style.cursor = 'grab';
|
||
});
|
||
|
||
modalImageWrapper.addEventListener('mousemove', (e) => {
|
||
const isImage = modalImageWrapper.querySelector('img') !== null;
|
||
if (!isDragging || !isZoomed || !isImage) return;
|
||
e.preventDefault();
|
||
dragMoved = true;
|
||
const x = e.pageX - modalImageWrapper.offsetLeft;
|
||
const y = e.pageY - modalImageWrapper.offsetTop;
|
||
const walkX = x - startX;
|
||
const walkY = y - startY;
|
||
modalImageWrapper.scrollLeft = scrollLeft - walkX;
|
||
modalImageWrapper.scrollTop = scrollTop - walkY;
|
||
});
|
||
|
||
modalImageWrapper.addEventListener('click', (e) => {
|
||
if (dragMoved) {
|
||
dragMoved = false;
|
||
return;
|
||
}
|
||
toggleZoom();
|
||
});
|
||
|
||
// Touch-swipe prev/next. Skipped when zoomed (drag-to-pan wins) or on
|
||
// multi-touch (pinch). Setting dragMoved suppresses the synthesized
|
||
// click that would otherwise toggle zoom after navigation.
|
||
const SWIPE_MIN_DISTANCE = 50;
|
||
const SWIPE_DOMINANCE = 1.5;
|
||
let touchStartX = null;
|
||
let touchStartY = null;
|
||
|
||
modalImageWrapper.addEventListener('touchstart', (e) => {
|
||
if (isZoomed || e.touches.length !== 1) {
|
||
touchStartX = null;
|
||
return;
|
||
}
|
||
touchStartX = e.touches[0].clientX;
|
||
touchStartY = e.touches[0].clientY;
|
||
}, { passive: true });
|
||
|
||
modalImageWrapper.addEventListener('touchend', (e) => {
|
||
if (touchStartX === null) return;
|
||
const touch = e.changedTouches[0];
|
||
const dx = touch.clientX - touchStartX;
|
||
const dy = touch.clientY - touchStartY;
|
||
touchStartX = null;
|
||
const absDx = Math.abs(dx);
|
||
const absDy = Math.abs(dy);
|
||
if (absDx < SWIPE_MIN_DISTANCE) return;
|
||
if (absDx < absDy * SWIPE_DOMINANCE) return;
|
||
dragMoved = true;
|
||
if (dx > 0) showPrev(); else showNext();
|
||
});
|
||
|
||
modalClose?.addEventListener('click', closeModal);
|
||
modalPrev?.addEventListener('click', showPrev);
|
||
modalNext?.addEventListener('click', showNext);
|
||
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) closeModal();
|
||
});
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (!modal.classList.contains('active')) return;
|
||
if (e.key === 'ArrowRight') showNext();
|
||
if (e.key === 'ArrowLeft') showPrev();
|
||
if (e.key === 'Escape') closeModal();
|
||
});
|
||
|
||
// 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'));
|
||
});
|
||
|
||
// Listen for gallery infinite scroll updates to refresh the images array
|
||
document.addEventListener('galleryUpdated', () => {
|
||
images = Array.from(document.querySelectorAll('.img-clickable'));
|
||
});
|
||
|
||
window.addEventListener('popstate', () => {
|
||
if (modal.classList.contains('active')) {
|
||
closeModal();
|
||
} else {
|
||
location.reload();
|
||
}
|
||
});
|
||
|
||
if (suggestionsRefresh) {
|
||
suggestionsRefresh.addEventListener('click', () => {
|
||
const currentId = getEditorImageId();
|
||
if (currentId) loadSuggestions(currentId);
|
||
});
|
||
}
|
||
});
|