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

+40 -6
View File
@@ -3,16 +3,16 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Meme Protocol Review</title>
<title>The Meme Protocol Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/styles.css">
<script type="module" src="/assets/admin.js"></script>
<link rel="stylesheet" href="/assets/styles.css?v=admin-metadata-20260930">
<script type="module" src="/assets/admin.js?v=admin-metadata-20260930"></script>
</head>
<body>
<body class="admin-page">
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL_REVIEW</div>
<div class="brand">THE_MEME_PROTOCOL_ADMIN</div>
<div class="admin-actions">
<button class="secondary-action" id="refresh-review" type="button">REFRESH</button>
<button class="primary-action" id="approve-selected" type="button">APPROVE_SELECTED</button>
@@ -25,7 +25,41 @@
<span id="review-status">PENDING_REVIEW</span>
<span class="live"><span class="pulse"></span><span id="review-count">0 ITEMS</span></span>
</div>
<section class="meme-grid" id="review-grid" aria-label="Pending meme review"></section>
<details class="admin-tag-manager">
<summary>Manage tags</summary>
<p>AI chooses from these tags. Removing a tag also removes it from every image.</p>
<form id="tag-form" class="admin-toolbar">
<label for="tag-name">New tag</label>
<input id="tag-name" name="name" maxlength="60" required placeholder="e.g. cats">
<button class="secondary-action" type="submit">ADD TAG</button>
</form>
<div id="tag-catalog" class="admin-tag-list"></div>
</details>
<form id="review-filters" class="admin-toolbar">
<label for="review-filter">Images</label>
<select id="review-filter">
<option value="pending">Pending</option>
<option value="approved">Published</option>
<option value="all">All images</option>
</select>
<label for="review-query">Search</label>
<input id="review-query" type="search" maxlength="200" placeholder="Caption or topic">
<label for="review-tag">Tag</label>
<select id="review-tag"><option value="">All tags</option></select>
<button class="secondary-action" type="submit">SEARCH</button>
</form>
<div class="admin-toolbar">
<button class="secondary-action" id="generate-selected" type="button">GENERATE AI FOR SELECTED</button>
<button class="secondary-action" id="generate-missing" type="button">GENERATE ALL MISSING CAPTIONS</button>
<button class="secondary-action" id="stop-generation" type="button" hidden>STOP AFTER CURRENT IMAGE</button>
<p>AI generation uses the configured model and can be edited below.</p>
</div>
<section class="meme-grid" id="review-grid" aria-label="Manage memes"></section>
<nav class="admin-toolbar admin-pagination" aria-label="Image pages">
<button id="previous-page" class="secondary-action" type="button">PREVIOUS</button>
<span id="page-status">PAGE 1 OF 1</span>
<button id="next-page" class="secondary-action" type="button">NEXT</button>
</nav>
</main>
</body>
</html>
+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;');
}
+124
View File
@@ -1132,3 +1132,127 @@ dialog::backdrop {
animation: none;
}
}
/* Image metadata administration */
.admin-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin: 20px 0;
color: var(--outline);
font-size: 12px;
}
.admin-toolbar input,
.admin-toolbar select,
.metadata-editor textarea {
background: var(--surface, #080808);
border: 1px solid var(--outline-variant);
color: var(--on-surface, #eee);
padding: 10px;
font: inherit;
max-width: 100%;
}
.admin-toolbar input:focus,
.admin-toolbar select:focus,
.metadata-editor textarea:focus {
outline: 1px solid var(--primary);
}
.admin-tag-manager {
border: 1px solid var(--outline-variant);
padding: 16px;
color: var(--outline);
font-size: 12px;
}
.admin-tag-manager summary {
color: var(--primary);
cursor: pointer;
}
.admin-tag-list,
.image-tag-options {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.admin-tag {
display: inline-flex;
align-items: center;
gap: 12px;
border: 1px solid var(--outline-variant);
padding: 8px;
}
.metadata-editor {
display: grid;
gap: 12px;
padding: 12px;
color: var(--outline);
font-size: 12px;
}
.metadata-editor textarea {
width: 100%;
resize: vertical;
line-height: 1.5;
}
.metadata-editor fieldset {
min-width: 0;
border: 1px solid var(--outline-variant);
padding: 10px;
}
.image-tag-options {
max-height: 160px;
overflow-y: auto;
}
.image-tag-options label {
display: flex;
align-items: center;
gap: 6px;
}
.image-tag-options input {
accent-color: var(--primary);
}
.admin-pagination {
justify-content: center;
}
.admin-busy button:not(#stop-generation),
.admin-busy input,
.admin-busy textarea,
.admin-busy select {
pointer-events: none;
opacity: 0.65;
}
.admin-toolbar [hidden] {
display: none;
}
button:disabled {
opacity: 0.4;
cursor: default;
}
.admin-page .topbar {
position: sticky;
height: auto;
min-height: 64px;
flex-wrap: wrap;
gap: 12px;
padding-block: 12px;
}
.admin-page .shell {
padding-top: 24px;
}