feat: manage image captions and tags from the admin panel

This commit is contained in:
smileBeda committed 2026-09-30 16:25:23 -03:00
1 parent 137cad1d84
commit b3f56ab285
4 files changed
+409 -84

No files matched your search

+223 -77
View File
@@ -4,11 +4,70 @@ 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;
document.querySelector('#refresh-review').addEventListener('click', loadPending);
document.querySelector('#approve-selected').addEventListener('click', () => moderateSelected('approve'));
document.querySelector('#delete-selected').addEventListener('click', () => moderateSelected('delete'));
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]');
@@ -16,101 +75,188 @@ grid.addEventListener('change', (event) => {
if (checkbox.checked) selected.add(checkbox.dataset.selectId);
else selected.delete(checkbox.dataset.selectId);
});
grid.addEventListener('click', (event) => {
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;
moderate([action.dataset.id], action.dataset.adminAction);
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 loadPending();
await handle(async () => { await loadTags(); await loadMemes(); })(new Event('load'));
async function loadPending() {
selected.clear();
reviewStatus.textContent = 'FETCHING_PENDING_QUEUE';
const response = await fetch('/api/admin/pending', { headers: adminHeaders() });
const payload = await response.json();
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_AUTH_FAILED';
grid.innerHTML = '<div class="empty-state">ADMIN_TOKEN_INVALID</div>';
return;
}
reviewStatus.textContent = 'PENDING_REVIEW';
reviewCount.textContent = `${payload.total} ITEMS`;
renderPending(payload.memes);
}
function renderPending(memes) {
if (memes.length === 0) {
grid.innerHTML = '<div class="empty-state">NO_PENDING_MEMES</div>';
return;
}
grid.replaceChildren(...memes.map((meme) => {
const article = document.createElement('article');
article.className = 'meme-card';
article.innerHTML = `
<div class="card-head">
<label class="review-select">
<input type="checkbox" data-select-id="${meme.id}">
<span>ID: ${shortId(meme.id)}</span>
</label>
<span class="card-meta age">SCORE ${meme.moderationScore}</span>
</div>
<div class="image-frame">
<img src="/admin-media/${token}/${meme.id}" alt="Pending meme ${shortId(meme.id)}" loading="lazy">
</div>
<div class="review-body">
<p>${escapeText(meme.moderationReason || 'Queued for review.')}</p>
</div>
<div class="card-actions">
<div class="stats">
<span class="card-stat">${formatBytes(meme.byteSize)}</span>
</div>
<div class="action-group">
<button class="text-action text-action-accent icon-only-action" type="button" data-admin-action="approve" data-id="${meme.id}" aria-label="Approve meme" title="Approve meme"><span class="material-symbols-outlined" aria-hidden="true">check</span></button>
<button class="text-action icon-only-action danger-action" type="button" data-admin-action="delete" data-id="${meme.id}" aria-label="Delete meme" title="Delete meme"><span class="material-symbols-outlined" aria-hidden="true">delete</span></button>
</div>
</div>
`;
return article;
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 moderateSelected(action) {
if (selected.size === 0) return;
await moderate([...selected], action);
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 = '<div class="empty-state">NO_MATCHING_MEMES</div>';
}
function renderCard(meme) {
const article = document.createElement('article');
article.className = 'meme-card';
article.dataset.memeId = meme.id;
article.innerHTML = `
<div class="card-head">
<label class="review-select"><input type="checkbox" data-select-id="${meme.id}"><span>ID: ${shortId(meme.id)}</span></label>
<span class="card-meta age">${meme.status.toUpperCase()} · ${meme.moderationScore}</span>
</div>
<div class="image-frame"><img src="/admin-media/${token}/${meme.id}" alt="${escapeText(meme.caption || `Meme ${shortId(meme.id)}`)}" loading="lazy"></div>
<div class="review-body"><p>${escapeText(meme.moderationReason || 'No moderation reason.')}</p></div>
<form class="metadata-editor" data-edit-id="${meme.id}">
<label for="caption-${meme.id}">Caption</label>
<textarea id="caption-${meme.id}" name="caption" rows="4" maxlength="2000">${escapeText(meme.caption || '')}</textarea>
<fieldset><legend>Tags</legend><div class="image-tag-options"></div></fieldset>
<button class="secondary-action" type="submit">SAVE METADATA</button>
</form>
<div class="card-actions">
<div class="stats"><span class="card-stat">${formatBytes(meme.byteSize)}</span></div>
<div class="action-group">
<button class="text-action" type="button" data-admin-action="generate" data-id="${meme.id}">GENERATE AI</button>
${meme.status === 'pending' ? `<button class="text-action text-action-accent" type="button" data-admin-action="approve" data-id="${meme.id}">APPROVE</button>` : ''}
<button class="text-action danger-action" type="button" data-admin-action="delete" data-id="${meme.id}">DELETE</button>
</div>
</div>`;
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) {
const endpoint = action === 'approve' ? '/api/admin/approve' : '/api/admin/delete';
const response = await fetch(endpoint, {
method: 'POST',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_ACTION_FAILED';
return;
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;
}
await loadPending();
reviewStatus.textContent = `GENERATED ${done} OF ${ids.length}`;
}
function adminHeaders() {
return { 'X-Admin-Token': token };
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 shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
function freezeEditors(disabled) {
document.querySelectorAll('.metadata-editor input, .metadata-editor textarea').forEach((control) => { control.disabled = disabled; });
}
function formatBytes(value) {
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)}MB`;
return `${Math.ceil(value / 1024)}KB`;
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;
return span.innerHTML.replaceAll('"', '&quot;');
}