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
@@ -39,6 +39,7 @@ Files are stored under sharded date/hash paths:
|
|||||||
```text
|
```text
|
||||||
data/
|
data/
|
||||||
index/memes.jsonl
|
index/memes.jsonl
|
||||||
|
index/tags.json
|
||||||
memes/YYYY/MM/DD/aa/bb/<sha256>.<ext>
|
memes/YYYY/MM/DD/aa/bb/<sha256>.<ext>
|
||||||
meta/YYYY/MM/DD/aa/bb/<sha256>.json
|
meta/YYYY/MM/DD/aa/bb/<sha256>.json
|
||||||
```
|
```
|
||||||
@@ -60,7 +61,8 @@ Set `SITE_URL` in production so canonical and API discovery URLs use the public
|
|||||||
|
|
||||||
## Public API
|
## Public API
|
||||||
|
|
||||||
- `GET /api/memes?page=1&pageSize=12`: lists approved memes only. `pageSize` is capped at `48`.
|
- `GET /api/memes?page=1&pageSize=12`: lists approved memes with `caption` and `tags`. `pageSize` is capped at `48`. Add `q=angry%20cat` for ranked caption/topic search and `tag=cats` for a topic filter. Repeat `tag` to require all selected topics.
|
||||||
|
- `GET /api/tags`: lists the available managed tag names.
|
||||||
- `GET /api/memes/<sha256>`: returns public metadata for one approved meme.
|
- `GET /api/memes/<sha256>`: returns public metadata for one approved meme.
|
||||||
- `GET /meme/<sha256>`: opens an approved meme in the viewer and provides its shareable permalink.
|
- `GET /meme/<sha256>`: opens an approved meme in the viewer and provides its shareable permalink.
|
||||||
- `GET /media/<sha256>`: returns the normalized WebP image for one approved meme.
|
- `GET /media/<sha256>`: returns the normalized WebP image for one approved meme.
|
||||||
@@ -70,6 +72,25 @@ API uploads use the exact same path as the browser form: persistent IP upload qu
|
|||||||
|
|
||||||
Public API reads are rate-limited per IP and return `429` with `Retry-After` when exceeded. List and metadata responses include `RateLimit-*` headers and a `rateLimit` object.
|
Public API reads are rate-limited per IP and return `429` with `Retry-After` when exceeded. List and metadata responses include `RateLimit-*` headers and a `rateLimit` object.
|
||||||
|
|
||||||
|
## Captions, tags, and administration
|
||||||
|
|
||||||
|
The LLM moderation call also writes a factual searchable caption and chooses relevant tags from the admin-managed catalog. The server filters out invented tags. Uploaders cannot supply captions or tags; reads and search are public, while every metadata mutation requires the existing `X-Admin-Token` authentication.
|
||||||
|
|
||||||
|
Search is lexical: case/accent-insensitive words, prefixes, and small spelling corrections, ranked by relevance then newest first. All query words and all selected tags must match. It does not use embeddings or infer semantic synonyms. Limits: 200 query characters, 20 tag filters, 200 managed tags, 60 characters per tag, and 2000 caption characters.
|
||||||
|
|
||||||
|
Open `/admin/<ADMIN_TOKEN>` to manage pending, published, or all images; edit captions and assigned tags; and add or remove topics. Tag names are lowercase and accept letters, numbers, spaces, underscores, and hyphens. Removing a tag clears its assignments from every image.
|
||||||
|
|
||||||
|
Existing records load with empty captions/tags; no offline migration is needed. Define your topics, then use **GENERATE ALL MISSING CAPTIONS** to backfill all existing images. The browser processes images sequentially, saves each result immediately, and can stop after the current image. Re-running resumes missing captions. **GENERATE AI FOR SELECTED** can regenerate metadata for existing captions (for example after adding topics). Generation uses `OPENAI_API_KEY` and `OPENAI_MODERATION_MODEL`, incurs normal model usage, and leaves existing metadata and approval status intact on failure. Manual edits made while an AI request is running are preserved via a version check. Without an API key, uploads remain pending with empty metadata, and admins can still edit manually.
|
||||||
|
|
||||||
|
Authenticated admin endpoints (not part of the public OpenAPI):
|
||||||
|
|
||||||
|
- `GET /api/admin/memes?page=1&pageSize=12&status=all`: paginated image management; `status` accepts `pending`, `approved`, or `all`. Supports `q`, repeated `tag`, and `missingCaption=true`.
|
||||||
|
- `PATCH /api/admin/memes/<sha256>` with `{ "caption": "...", "tags": ["cats"] }`: edit either or both metadata fields; unknown tags are rejected.
|
||||||
|
- `POST /api/admin/memes/<sha256>/generate`: generate caption and tags without re-moderating; optional `{ "onlyMissing": true }` skips already-captioned images.
|
||||||
|
- `GET /api/admin/tags`, `POST /api/admin/tags` with `{ "name": "cats" }`, and `DELETE /api/admin/tags/<URL-encoded-name>`: manage the catalog.
|
||||||
|
|
||||||
|
Admin responses use `Cache-Control: no-store`. Unauthenticated admin API requests return `404`.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+40
-6
@@ -3,16 +3,16 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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">
|
<link rel="stylesheet" href="/assets/styles.css?v=admin-metadata-20260930">
|
||||||
<script type="module" src="/assets/admin.js"></script>
|
<script type="module" src="/assets/admin.js?v=admin-metadata-20260930"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="admin-page">
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">THE_MEME_PROTOCOL_REVIEW</div>
|
<div class="brand">THE_MEME_PROTOCOL_ADMIN</div>
|
||||||
<div class="admin-actions">
|
<div class="admin-actions">
|
||||||
<button class="secondary-action" id="refresh-review" type="button">REFRESH</button>
|
<button class="secondary-action" id="refresh-review" type="button">REFRESH</button>
|
||||||
<button class="primary-action" id="approve-selected" type="button">APPROVE_SELECTED</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 id="review-status">PENDING_REVIEW</span>
|
||||||
<span class="live"><span class="pulse"></span><span id="review-count">0 ITEMS</span></span>
|
<span class="live"><span class="pulse"></span><span id="review-count">0 ITEMS</span></span>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+223
-77
@@ -4,11 +4,70 @@ history.replaceState(null, '', '/admin');
|
|||||||
const grid = document.querySelector('#review-grid');
|
const grid = document.querySelector('#review-grid');
|
||||||
const reviewStatus = document.querySelector('#review-status');
|
const reviewStatus = document.querySelector('#review-status');
|
||||||
const reviewCount = document.querySelector('#review-count');
|
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 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);
|
function handle(action) {
|
||||||
document.querySelector('#approve-selected').addEventListener('click', () => moderateSelected('approve'));
|
return async (event) => {
|
||||||
document.querySelector('#delete-selected').addEventListener('click', () => moderateSelected('delete'));
|
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) => {
|
grid.addEventListener('change', (event) => {
|
||||||
const checkbox = event.target.closest('[data-select-id]');
|
const checkbox = event.target.closest('[data-select-id]');
|
||||||
@@ -16,101 +75,188 @@ grid.addEventListener('change', (event) => {
|
|||||||
if (checkbox.checked) selected.add(checkbox.dataset.selectId);
|
if (checkbox.checked) selected.add(checkbox.dataset.selectId);
|
||||||
else selected.delete(checkbox.dataset.selectId);
|
else selected.delete(checkbox.dataset.selectId);
|
||||||
});
|
});
|
||||||
|
grid.addEventListener('input', (event) => {
|
||||||
grid.addEventListener('click', (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]');
|
const action = event.target.closest('[data-admin-action]');
|
||||||
if (!action) return;
|
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() {
|
async function loadTags() {
|
||||||
selected.clear();
|
({ tags } = await request('/api/admin/tags'));
|
||||||
reviewStatus.textContent = 'FETCHING_PENDING_QUEUE';
|
const previous = tagFilter.value;
|
||||||
const response = await fetch('/api/admin/pending', { headers: adminHeaders() });
|
tagFilter.replaceChildren(new Option('All tags', ''), ...tags.map((tag) => new Option(tag, tag)));
|
||||||
const payload = await response.json();
|
tagFilter.value = tags.includes(previous) ? previous : '';
|
||||||
if (!response.ok) {
|
const catalog = document.querySelector('#tag-catalog');
|
||||||
reviewStatus.textContent = 'REVIEW_AUTH_FAILED';
|
catalog.replaceChildren(...tags.map((tag) => {
|
||||||
grid.innerHTML = '<div class="empty-state">ADMIN_TOKEN_INVALID</div>';
|
const item = document.createElement('span');
|
||||||
return;
|
item.className = 'admin-tag';
|
||||||
}
|
const name = document.createElement('span');
|
||||||
reviewStatus.textContent = 'PENDING_REVIEW';
|
name.textContent = tag;
|
||||||
reviewCount.textContent = `${payload.total} ITEMS`;
|
const button = document.createElement('button');
|
||||||
renderPending(payload.memes);
|
button.type = 'button';
|
||||||
}
|
button.className = 'text-action danger-action';
|
||||||
|
button.textContent = 'REMOVE';
|
||||||
function renderPending(memes) {
|
button.dataset.removeTag = tag;
|
||||||
if (memes.length === 0) {
|
button.setAttribute('aria-label', `Remove tag ${tag}`);
|
||||||
grid.innerHTML = '<div class="empty-state">NO_PENDING_MEMES</div>';
|
item.append(name, button);
|
||||||
return;
|
return item;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}));
|
}));
|
||||||
|
if (!tags.length) catalog.textContent = 'No tags yet. Add topics before generating AI metadata.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function moderateSelected(action) {
|
async function loadMemes() {
|
||||||
if (selected.size === 0) return;
|
selected.clear();
|
||||||
await moderate([...selected], action);
|
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) {
|
async function moderate(ids, action) {
|
||||||
const endpoint = action === 'approve' ? '/api/admin/approve' : '/api/admin/delete';
|
if (!ids.length || !allowReload()) return;
|
||||||
const response = await fetch(endpoint, {
|
if (action === 'delete' && !confirm(`Permanently delete ${ids.length} image(s)?`)) return;
|
||||||
method: 'POST',
|
await request(`/api/admin/${action}`, 'POST', { ids });
|
||||||
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
|
await loadMemes();
|
||||||
body: JSON.stringify({ ids })
|
}
|
||||||
});
|
|
||||||
if (!response.ok) {
|
async function generateMany(ids) {
|
||||||
reviewStatus.textContent = 'REVIEW_ACTION_FAILED';
|
if (!ids.length) return;
|
||||||
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() {
|
async function backfill() {
|
||||||
return { 'X-Admin-Token': token };
|
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) {
|
function freezeEditors(disabled) {
|
||||||
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
|
document.querySelectorAll('.metadata-editor input, .metadata-editor textarea').forEach((control) => { control.disabled = disabled; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(value) {
|
function allowReload() {
|
||||||
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)}MB`;
|
return !dirty.size || confirm('Discard unsaved image metadata edits?');
|
||||||
return `${Math.ceil(value / 1024)}KB`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function escapeText(value) {
|
||||||
const span = document.createElement('span');
|
const span = document.createElement('span');
|
||||||
span.textContent = value;
|
span.textContent = value;
|
||||||
return span.innerHTML;
|
return span.innerHTML.replaceAll('"', '"');
|
||||||
}
|
}
|
||||||
@@ -1132,3 +1132,127 @@ dialog::backdrop {
|
|||||||
animation: none;
|
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