3f11dcdc6c
1. Hide 'archive' kind from /tags default view. Archive tags only appear when the user explicitly requests them via ?kind=archive. _get_tags_with_previews gains a default_excluded_kinds tuple that applies when no explicit kind filter is set. 2. Hide the 'Add' submit button on desktop (>=768px). Enter in the tag input (and in the fandom picker when no row is highlighted) already submits; the button is only needed for mobile virtual keyboards where Enter-to-submit is unreliable. 3. Fandom picker keyboard parity: ArrowUp/Down navigate rows, Enter picks a highlighted row or (if no row is highlighted) submits the whole add-tag form. Escape clears the highlight. Mirrors the main tagInput's keyboard block. 4. Tag editor merge bug: the edit modal populated nameInput.value with data.tag.display_name — for a character with a fandom, that value is 'Name (Fandom)'. Saving without stripping the suffix routed to a rename that never collided with the canonical bare name, so the merge offer never fired. Now uses data.tag.name (the bare form). The (Fandom) display is still shown in the fandom input which is rendered separately. 5. Fandom picker styling: match the main tag-form input — same padding, border, background, focus state. Drop the separate 'Fandom' label; the placeholder 'Fandom (optional)' now carries the label role. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1282 lines
47 KiB
JavaScript
1282 lines
47 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 fandomPickerWrapper = document.getElementById('fandomPickerWrapper');
|
||
const fandomPickerInput = document.getElementById('fandomPickerInput');
|
||
const fandomPickerAutocomplete = document.getElementById('fandomPickerAutocomplete');
|
||
const fandomPickerClear = document.getElementById('fandomPickerClear');
|
||
let fandomPickerDebounce = null;
|
||
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);
|
||
// Use display_name so character pills show the "(Fandom)" suffix.
|
||
const label = t.display_name || t.name;
|
||
chip.innerHTML = `${icon} ${label} <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';
|
||
const name = chip.dataset.tagName || '';
|
||
const category = chip.dataset.category || '';
|
||
const source = chip.dataset.source || '';
|
||
const confidence = Number(chip.dataset.confidence) || 0;
|
||
try {
|
||
const r = await fetch(`/image/${imageId}/suggestions/accept`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name, category, source, confidence }),
|
||
});
|
||
if (r.status === 409) {
|
||
const body = await r.json();
|
||
if (body && body.error === 'ambiguous' && Array.isArray(body.candidates)) {
|
||
renderAmbiguousPicker(chip, body.candidates, { name, category, source, confidence, imageId });
|
||
return; // chip stays disabled; picker handles next step
|
||
}
|
||
}
|
||
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.display_name || j.tag.name}`;
|
||
if (typeof window.refreshItemTags === 'function') window.refreshItemTags(imageId);
|
||
animateChipOut(chip);
|
||
} catch {
|
||
chip.dataset.disabled = 'false';
|
||
chip.style.pointerEvents = '';
|
||
}
|
||
}
|
||
|
||
function renderAmbiguousPicker(chip, candidates, ctx) {
|
||
const picker = document.createElement('div');
|
||
picker.className = 'suggestion-ambiguous-picker';
|
||
|
||
const label = document.createElement('div');
|
||
label.className = 'ambiguous-label';
|
||
label.textContent = 'Which one?';
|
||
picker.appendChild(label);
|
||
|
||
for (const c of candidates) {
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'ambiguous-candidate';
|
||
btn.dataset.tagId = String(c.id);
|
||
btn.textContent = c.display_name;
|
||
btn.addEventListener('click', async () => {
|
||
picker.querySelectorAll('button').forEach(b => { b.disabled = true; });
|
||
try {
|
||
const r = await fetch(`/image/${ctx.imageId}/suggestions/accept`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: ctx.name,
|
||
category: ctx.category,
|
||
source: ctx.source,
|
||
confidence: ctx.confidence,
|
||
tag_id: c.id,
|
||
}),
|
||
});
|
||
const j = await r.json();
|
||
if (!j.ok) {
|
||
picker.querySelectorAll('button').forEach(b => { b.disabled = false; });
|
||
if (tagActionFeedback) {
|
||
tagActionFeedback.textContent = `Couldn't accept: ${j.error || 'error'}`;
|
||
}
|
||
return;
|
||
}
|
||
if (typeof loadTags === 'function') await loadTags(ctx.imageId);
|
||
if (tagActionFeedback) tagActionFeedback.textContent = `Added ${j.tag.display_name || j.tag.name}`;
|
||
if (typeof window.refreshItemTags === 'function') window.refreshItemTags(ctx.imageId);
|
||
animateChipOut(chip);
|
||
} catch {
|
||
picker.querySelectorAll('button').forEach(b => { b.disabled = false; });
|
||
}
|
||
});
|
||
picker.appendChild(btn);
|
||
}
|
||
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.type = 'button';
|
||
cancelBtn.className = 'ambiguous-cancel';
|
||
cancelBtn.textContent = 'Cancel';
|
||
cancelBtn.addEventListener('click', () => {
|
||
picker.remove();
|
||
chip.dataset.disabled = 'false';
|
||
chip.style.pointerEvents = '';
|
||
});
|
||
picker.appendChild(cancelBtn);
|
||
|
||
chip.appendChild(picker);
|
||
}
|
||
|
||
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;
|
||
opt.textContent = tag.display_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;
|
||
|
||
if (seriesNameEl) {
|
||
seriesNameEl.textContent = seriesPage.series_name;
|
||
seriesNameEl.href = `/gallery?tag_id=${encodeURIComponent(seriesPage.series_tag_id)}`;
|
||
}
|
||
|
||
// 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, { tagId, fandomId, fandomName } = {}) {
|
||
const fd = new FormData();
|
||
if (tagId) {
|
||
// Autocomplete-picker path: server attaches this exact tag, skipping
|
||
// parse_kind_prefix so a same-name character/user ambiguity is
|
||
// resolved by the row the user clicked.
|
||
fd.append('tag_id', String(tagId));
|
||
} else {
|
||
fd.append('name', name);
|
||
if (fandomId) {
|
||
fd.append('fandom_id', String(fandomId));
|
||
} else if (fandomName) {
|
||
fd.append('fandom_name', fandomName);
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
|
||
// ---------------------------
|
||
// Fandom picker helpers
|
||
// ---------------------------
|
||
function clearFandomAutocomplete() {
|
||
if (fandomPickerAutocomplete) fandomPickerAutocomplete.innerHTML = '';
|
||
}
|
||
|
||
function clearFandomPicker() {
|
||
if (!fandomPickerInput) return;
|
||
fandomPickerInput.value = '';
|
||
delete fandomPickerInput.dataset.fandomId;
|
||
clearFandomAutocomplete();
|
||
fandomPickerWrapper?.classList.remove('visible');
|
||
fandomPickerWrapper?.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
function renderFandomAutocomplete(rows) {
|
||
if (!fandomPickerAutocomplete) return;
|
||
if (!rows || rows.length === 0) {
|
||
fandomPickerAutocomplete.innerHTML = '';
|
||
return;
|
||
}
|
||
fandomPickerAutocomplete.innerHTML = rows.map(r => {
|
||
const name = String(r.name || '').replace(/"/g, '"');
|
||
const displayName = String(r.display_name || r.name || '').replace(/</g, '<');
|
||
return `<div class="tag-autocomplete-item" data-fandom-id="${r.id}" data-fandom-name="${name}">${displayName}</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ---------------------------
|
||
// 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);
|
||
// Use display_name from the server so character+fandom rows show
|
||
// "Ruby Rose (RWBY)" — lets the user disambiguate same-name characters.
|
||
const label = t.display_name || t.name;
|
||
return `<div class="tag-autocomplete-item" data-index="${i}" data-tag-id="${t.id}" data-name="${t.name}" data-kind="${t.kind || ''}">
|
||
<span>${icon} ${label}</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).
|
||
// `pickedTagId` is set when the user clicks an autocomplete row or presses
|
||
// Enter on a highlighted row — in that case, skip parsing and attach that
|
||
// exact tag so same-name character/user ambiguity is resolved by the pick.
|
||
async function submitTag(pickedTagId = null) {
|
||
const id = getEditorImageId();
|
||
const name = (tagInput?.value || '').trim();
|
||
if (!id || (!name && !pickedTagId)) return;
|
||
const fandomId = fandomPickerInput?.dataset?.fandomId;
|
||
const fandomName = (fandomPickerInput?.value || '').trim();
|
||
const ok = await addTag(id, name, {
|
||
tagId: pickedTagId || null,
|
||
fandomId: fandomId ? Number(fandomId) : null,
|
||
fandomName: fandomName || null,
|
||
});
|
||
if (tagInput) tagInput.value = '';
|
||
clearFandomPicker();
|
||
// 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 + toggle the fandom picker when the shortcut is 'character:'.
|
||
tagInput.addEventListener('input', () => {
|
||
const lower = (tagInput.value || '').toLowerCase();
|
||
const isCharacter = lower.startsWith('character:');
|
||
if (fandomPickerWrapper) {
|
||
fandomPickerWrapper.classList.toggle('visible', isCharacter);
|
||
fandomPickerWrapper.setAttribute('aria-hidden', String(!isCharacter));
|
||
}
|
||
if (!isCharacter) {
|
||
// Typing anything else cancels a previously-staged fandom id/name.
|
||
if (fandomPickerInput) {
|
||
fandomPickerInput.value = '';
|
||
delete fandomPickerInput.dataset.fandomId;
|
||
clearFandomAutocomplete();
|
||
}
|
||
}
|
||
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.display_name || selected.name;
|
||
submitTag(selected.id);
|
||
}
|
||
} 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;
|
||
const tagId = item.dataset.tagId ? Number(item.dataset.tagId) : null;
|
||
if (tagInput && name) {
|
||
tagInput.value = name;
|
||
submitTag(tagId);
|
||
}
|
||
});
|
||
}
|
||
|
||
let fandomPickerSelectedIdx = -1;
|
||
|
||
function highlightFandomItem(idx) {
|
||
if (!fandomPickerAutocomplete) return;
|
||
const items = fandomPickerAutocomplete.querySelectorAll('.tag-autocomplete-item');
|
||
items.forEach((el, i) => el.classList.toggle('selected', i === idx));
|
||
fandomPickerSelectedIdx = idx;
|
||
}
|
||
|
||
function pickFandomRow(row) {
|
||
if (!row || !fandomPickerInput) return;
|
||
fandomPickerInput.value = row.dataset.fandomName;
|
||
fandomPickerInput.dataset.fandomId = row.dataset.fandomId;
|
||
clearFandomAutocomplete();
|
||
fandomPickerSelectedIdx = -1;
|
||
}
|
||
|
||
if (fandomPickerInput) {
|
||
fandomPickerInput.addEventListener('input', () => {
|
||
clearTimeout(fandomPickerDebounce);
|
||
const term = fandomPickerInput.value.trim();
|
||
// Any typing invalidates a previously-selected fandom id + highlight.
|
||
delete fandomPickerInput.dataset.fandomId;
|
||
fandomPickerSelectedIdx = -1;
|
||
if (!term) {
|
||
clearFandomAutocomplete();
|
||
return;
|
||
}
|
||
fandomPickerDebounce = setTimeout(() => {
|
||
fetch(`/api/tags/search?kind=fandom&q=${encodeURIComponent(term)}&limit=8`)
|
||
.then(r => r.json())
|
||
.then(body => renderFandomAutocomplete(body.tags || []))
|
||
.catch(() => clearFandomAutocomplete());
|
||
}, 150);
|
||
});
|
||
|
||
// Keyboard parity with the main tagInput: ArrowDown/Up to navigate rows,
|
||
// Enter to pick a highlighted row (or submit the whole form when no row
|
||
// is highlighted), Escape to clear the highlight.
|
||
fandomPickerInput.addEventListener('keydown', (e) => {
|
||
const rows = fandomPickerAutocomplete
|
||
? fandomPickerAutocomplete.querySelectorAll('.tag-autocomplete-item')
|
||
: [];
|
||
const count = rows.length;
|
||
if (e.key === 'ArrowDown' && count > 0) {
|
||
e.preventDefault();
|
||
highlightFandomItem(Math.min(fandomPickerSelectedIdx + 1, count - 1));
|
||
} else if (e.key === 'ArrowUp' && count > 0) {
|
||
e.preventDefault();
|
||
highlightFandomItem(Math.max(fandomPickerSelectedIdx - 1, 0));
|
||
} else if (e.key === 'Enter') {
|
||
if (fandomPickerSelectedIdx >= 0 && rows[fandomPickerSelectedIdx]) {
|
||
e.preventDefault();
|
||
pickFandomRow(rows[fandomPickerSelectedIdx]);
|
||
} else {
|
||
e.preventDefault();
|
||
submitTag();
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
highlightFandomItem(-1);
|
||
}
|
||
});
|
||
}
|
||
|
||
if (fandomPickerAutocomplete) {
|
||
fandomPickerAutocomplete.addEventListener('mousedown', (e) => {
|
||
const row = e.target.closest('[data-fandom-id]');
|
||
if (!row) return;
|
||
e.preventDefault(); // keep focus on the input
|
||
pickFandomRow(row);
|
||
});
|
||
}
|
||
|
||
if (fandomPickerClear) {
|
||
fandomPickerClear.addEventListener('click', clearFandomPicker);
|
||
}
|
||
|
||
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 = '';
|
||
clearFandomPicker();
|
||
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() {
|
||
clearFandomPicker();
|
||
// 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);
|
||
});
|
||
}
|
||
});
|