263 lines
11 KiB
JavaScript
263 lines
11 KiB
JavaScript
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 = '<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) {
|
|
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('"', '"');
|
|
}
|