fb925f5e6a
ml-worker now fetches WD14 + SigLIP on container start via an entrypoint
script that calls huggingface_hub with a populated local_dir. HF does a
content-based integrity check, so subsequent starts are a no-op. Transient
HF outages warn and continue when a local copy is already present. This
drops the baked-in model layer (~4 GB) from the image so it pushes cleanly
through registry proxies and no longer bloats every tagged release.
view-modal.js restores the desktop-only auto-focus of the tag input on
openModal (with touch-device guard). The block existed in an earlier
main commit (0bf1961) but was absent from the post-corruption
consolidation commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1043 lines
37 KiB
JavaScript
1043 lines
37 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
|
||
// ---------------------------
|
||
function hideAutocomplete() {
|
||
if (tagAutocomplete) {
|
||
tagAutocomplete.classList.remove('active');
|
||
tagAutocomplete.innerHTML = '';
|
||
}
|
||
autocompleteItems = [];
|
||
autocompleteSelectedIndex = -1;
|
||
}
|
||
|
||
function positionAutocomplete() {
|
||
if (!tagAutocomplete || !tagInput) return;
|
||
const rect = tagInput.getBoundingClientRect();
|
||
tagAutocomplete.style.top = `${rect.bottom + 4}px`;
|
||
tagAutocomplete.style.left = `${rect.left}px`;
|
||
tagAutocomplete.style.width = `${rect.width}px`;
|
||
}
|
||
|
||
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>';
|
||
positionAutocomplete();
|
||
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('');
|
||
positionAutocomplete();
|
||
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 {
|
||
// 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=8&exclude_kind=${excludeKinds}`
|
||
: `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
|
||
const r = await fetch(url);
|
||
const j = await r.json();
|
||
renderAutocomplete(j.tags || []);
|
||
} catch {
|
||
hideAutocomplete();
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
hideAutocomplete();
|
||
await addTag(id, name);
|
||
if (tagInput) tagInput.value = '';
|
||
}
|
||
|
||
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;
|
||
submitTag();
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
hideAutocomplete();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Click on autocomplete item - use mousedown to fire before blur
|
||
if (tagAutocomplete) {
|
||
tagAutocomplete.addEventListener('mousedown', (e) => {
|
||
e.preventDefault(); // Prevent blur from firing
|
||
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);
|
||
loadTags(imageId);
|
||
loadSeriesInfo(imageId);
|
||
}
|
||
|
||
function openModal(index) {
|
||
modal.classList.add('active');
|
||
updateImage(index);
|
||
history.replaceState({ modalIndex: index }, '', window.location.href);
|
||
// Desktop only — skip on touch devices to avoid popping the on-screen keyboard
|
||
if (tagInput && !isTouchDevice()) {
|
||
setTimeout(() => tagInput.focus(), 100);
|
||
}
|
||
}
|
||
|
||
function isTouchDevice() {
|
||
return ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||
}
|
||
|
||
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('');
|
||
renderTags([]);
|
||
renderProvenance([]);
|
||
clearSuggestions();
|
||
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();
|
||
});
|
||
|
||
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);
|
||
});
|
||
}
|
||
});
|