feat: manage image captions and tags from the admin panel
This commit is contained in:
4 files changed
+409
-84
No files matched your search
+223
-77
@@ -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('"', '"');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user