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 = '
${escapeText(meme.moderationReason || 'No moderation reason.')}