const token = location.pathname.split('/').pop(); history.replaceState(null, '', '/admin'); const grid = document.querySelector('#review-grid'); const reviewStatus = document.querySelector('#review-status'); const reviewCount = document.querySelector('#review-count'); const statusFilter = document.querySelector('#review-filter'); const tagFilter = document.querySelector('#review-tag'); const queryInput = document.querySelector('#review-query'); const selected = new Set(); const dirty = new Set(); let tags = []; let page = 1; let totalPages = 1; let stopGeneration = false; let busy = false; function handle(action) { return async (event) => { if (event.type === 'submit') event.preventDefault(); if (busy) return; busy = true; document.body.classList.add('admin-busy'); try { await action(event); } catch (error) { reviewStatus.textContent = error.message; } finally { busy = false; document.body.classList.remove('admin-busy'); freezeEditors(false); document.querySelector('#stop-generation').hidden = true; } }; } document.querySelector('#refresh-review').addEventListener('click', handle(async () => { if (allowReload()) { await loadTags(); await loadMemes(); } })); document.querySelector('#approve-selected').addEventListener('click', handle(() => moderate([...selected], 'approve'))); document.querySelector('#delete-selected').addEventListener('click', handle(() => moderate([...selected], 'delete'))); document.querySelector('#generate-selected').addEventListener('click', handle(() => generateMany([...selected]))); document.querySelector('#generate-missing').addEventListener('click', handle(backfill)); document.querySelector('#stop-generation').addEventListener('click', () => { stopGeneration = true; }); document.querySelector('#review-filters').addEventListener('submit', handle(async () => { if (!allowReload()) return; page = 1; await loadMemes(); })); for (const [id, delta] of [['previous-page', -1], ['next-page', 1]]) { document.querySelector(`#${id}`).addEventListener('click', handle(async () => { if (!allowReload()) return; page = Math.max(1, Math.min(totalPages, page + delta)); await loadMemes(); })); } document.querySelector('#tag-form').addEventListener('submit', handle(async (event) => { if (!allowReload()) return; await request('/api/admin/tags', 'POST', { name: document.querySelector('#tag-name').value }); event.target.reset(); await loadTags(); await loadMemes(); })); document.querySelector('#tag-catalog').addEventListener('click', handle(async (event) => { const button = event.target.closest('[data-remove-tag]'); if (!button || !allowReload()) return; if (!confirm(`Remove “${button.dataset.removeTag}” from the catalog and every image?`)) return; await request(`/api/admin/tags/${encodeURIComponent(button.dataset.removeTag)}`, 'DELETE'); await loadTags(); await loadMemes(); })); grid.addEventListener('change', (event) => { const checkbox = event.target.closest('[data-select-id]'); if (!checkbox) return; if (checkbox.checked) selected.add(checkbox.dataset.selectId); else selected.delete(checkbox.dataset.selectId); }); grid.addEventListener('input', (event) => { const form = event.target.closest('[data-edit-id]'); if (form) dirty.add(form.dataset.editId); }); grid.addEventListener('submit', handle(async (event) => { const form = event.target.closest('[data-edit-id]'); if (!form) return; const id = form.dataset.editId; const data = new FormData(form); const { meme } = await request(`/api/admin/memes/${id}`, 'PATCH', { caption: data.get('caption'), tags: data.getAll('tags') }); dirty.delete(id); replaceCard(meme); reviewStatus.textContent = 'METADATA_SAVED'; })); grid.addEventListener('click', handle(async (event) => { const action = event.target.closest('[data-admin-action]'); if (!action) return; if (action.dataset.adminAction === 'generate') await generateMany([action.dataset.id]); else await moderate([action.dataset.id], action.dataset.adminAction); })); window.addEventListener('beforeunload', (event) => { if (dirty.size) { event.preventDefault(); event.returnValue = ''; } }); await handle(async () => { await loadTags(); await loadMemes(); })(new Event('load')); async function loadTags() { ({ tags } = await request('/api/admin/tags')); const previous = tagFilter.value; tagFilter.replaceChildren(new Option('All tags', ''), ...tags.map((tag) => new Option(tag, tag))); tagFilter.value = tags.includes(previous) ? previous : ''; const catalog = document.querySelector('#tag-catalog'); catalog.replaceChildren(...tags.map((tag) => { const item = document.createElement('span'); item.className = 'admin-tag'; const name = document.createElement('span'); name.textContent = tag; const button = document.createElement('button'); button.type = 'button'; button.className = 'text-action danger-action'; button.textContent = 'REMOVE'; button.dataset.removeTag = tag; button.setAttribute('aria-label', `Remove tag ${tag}`); item.append(name, button); return item; })); if (!tags.length) catalog.textContent = 'No tags yet. Add topics before generating AI metadata.'; } async function loadMemes() { selected.clear(); dirty.clear(); reviewStatus.textContent = 'LOADING_IMAGES'; const params = new URLSearchParams({ page, pageSize: 12, status: statusFilter.value, q: queryInput.value }); if (tagFilter.value) params.set('tag', tagFilter.value); const payload = await request(`/api/admin/memes?${params}`); page = payload.page; totalPages = payload.totalPages; reviewStatus.textContent = `${statusFilter.value.toUpperCase()}_IMAGES`; reviewCount.textContent = `${payload.total} ITEMS`; document.querySelector('#page-status').textContent = `PAGE ${page} OF ${totalPages}`; document.querySelector('#previous-page').disabled = page <= 1; document.querySelector('#next-page').disabled = page >= totalPages; grid.replaceChildren(...payload.memes.map(renderCard)); if (!payload.memes.length) grid.innerHTML = '
NO_MATCHING_MEMES
'; } function renderCard(meme) { const article = document.createElement('article'); article.className = 'meme-card'; article.dataset.memeId = meme.id; article.innerHTML = `
${meme.status.toUpperCase()} · ${meme.moderationScore}
${escapeText(meme.caption || `Meme ${shortId(meme.id)}`)}

${escapeText(meme.moderationReason || 'No moderation reason.')}

Tags
${formatBytes(meme.byteSize)}
${meme.status === 'pending' ? `` : ''}
`; const options = article.querySelector('.image-tag-options'); for (const tag of tags) { const label = document.createElement('label'); const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.name = 'tags'; checkbox.value = tag; checkbox.checked = meme.tags.includes(tag); label.append(checkbox, document.createTextNode(tag)); options.append(label); } if (!tags.length) options.textContent = 'Add topics in Manage tags.'; article.querySelector('[data-select-id]').checked = selected.has(meme.id); if (busy) article.querySelectorAll('.metadata-editor input, .metadata-editor textarea').forEach((control) => { control.disabled = true; }); return article; } function replaceCard(meme) { grid.querySelector(`[data-meme-id="${meme.id}"]`)?.replaceWith(renderCard(meme)); } async function moderate(ids, action) { if (!ids.length || !allowReload()) return; if (action === 'delete' && !confirm(`Permanently delete ${ids.length} image(s)?`)) return; await request(`/api/admin/${action}`, 'POST', { ids }); await loadMemes(); } async function generateMany(ids) { if (!ids.length) return; if (ids.some((id) => dirty.has(id)) && !confirm('AI generation will replace unsaved edits on these images. Continue?')) return; stopGeneration = false; freezeEditors(true); document.querySelector('#stop-generation').hidden = false; let done = 0; for (const id of ids) { if (stopGeneration) break; reviewStatus.textContent = `GENERATING ${done + 1} OF ${ids.length}`; const { meme } = await request(`/api/admin/memes/${id}/generate`, 'POST'); dirty.delete(id); replaceCard(meme); done += 1; } reviewStatus.textContent = `GENERATED ${done} OF ${ids.length}`; } async function backfill() { if (!allowReload()) return; stopGeneration = false; freezeEditors(true); document.querySelector('#stop-generation').hidden = false; let done = 0; // Read page one repeatedly as completed images leave the missing-caption set. while (!stopGeneration) { const batch = await request('/api/admin/memes?status=all&missingCaption=true&pageSize=48'); if (!batch.memes.length) break; for (const image of batch.memes) { if (stopGeneration) break; reviewStatus.textContent = `GENERATING MISSING CAPTIONS · ${done} DONE · ${batch.total} IN LAST BATCH`; const { meme } = await request(`/api/admin/memes/${image.id}/generate`, 'POST', { onlyMissing: true }); replaceCard(meme); done += 1; } } await loadMemes(); reviewStatus.textContent = `GENERATED ${done} MISSING CAPTIONS${stopGeneration ? ' · STOPPED' : ''}`; } function freezeEditors(disabled) { document.querySelectorAll('.metadata-editor input, .metadata-editor textarea').forEach((control) => { control.disabled = disabled; }); } function allowReload() { return !dirty.size || confirm('Discard unsaved image metadata edits?'); } async function request(url, method = 'GET', body) { const response = await fetch(url, { method, headers: { 'X-Admin-Token': token, ...(body ? { 'Content-Type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || 'Admin request failed.'); return payload; } function shortId(id) { return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`; } function formatBytes(value) { return value >= 1024 * 1024 ? `${(value / 1024 / 1024).toFixed(1)}MB` : `${Math.ceil(value / 1024)}KB`; } function escapeText(value) { const span = document.createElement('span'); span.textContent = value; return span.innerHTML.replaceAll('"', '"'); }