This commit is contained in:
2026-09-28 20:40:33 +02:00
parent dde0a2d271
commit b96f188a59
5 changed files with 320 additions and 32 deletions
+10
View File
@@ -3977,6 +3977,16 @@ body.sidebar-right-hidden #sidebar-drag-zone {
color: #fff;
}
/* Tuner presets bar + footer: wrap onto more lines instead of overflowing the sidebar */
.f0ck-tuner-presets-bar { min-width: 0; max-width: 100%; box-sizing: border-box; }
.f0ck-tuner-presets-select-wrap { min-width: 0; }
.f0ck-tuner-presets-select-wrap .f0ck-tuner-preset-select { min-width: 0; }
.f0ck-tuner-presets-actions { flex-wrap: wrap; min-width: 0; }
.f0ck-tuner-presets-actions .f0ck-tuner-preset-input { flex: 1 1 100%; min-width: 0; box-sizing: border-box; }
.f0ck-tuner-presets-actions > button { flex: 1 1 auto; justify-content: center; }
.f0ck-tuner-footer { flex-wrap: wrap; }
.f0ck-tuner-footer > button { flex: 1 1 auto; min-width: 0; }
/* ── Tuner: style guide (square, flat, theme colours, no glow) + touch-friendly sliders ── */
.f0ck-audio-tuner-panel,
.f0ck-audio-tuner-panel *,
+1 -7
View File
@@ -2274,14 +2274,8 @@ class CommentSystem {
: `<div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div>`;
const isAnon = isAnonGuest || comment.is_anon || comment.anon_fingerprint || comment.username === 'anonymous' || (comment.username && comment.username.startsWith('anon_'));
const anonFp = comment.anon_fingerprint || '';
const anonShortFp = comment.anon_short_fingerprint || (anonFp ? anonFp.slice(7, 15) : '');
const anonTripcodeHtml = anonShortFp
? ` <span class="anon-tripcode" data-fingerprint="${this.escapeHtml(anonFp)}" tooltip="${this.escapeHtml(anonFp)} (OpenSSH Ed25519)">[${this.escapeHtml(anonShortFp)}]</span>`
: '';
const authorHtml = isAnon
? `<span class="comment-author">anonymous</span>${anonTripcodeHtml}`
? `<span class="comment-author">anonymous</span>`
: (comment.username
? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>`
: '<span class="comment-author">System</span>');
-3
View File
@@ -21133,7 +21133,6 @@ document.addEventListener('DOMContentLoaded', () => {
clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
}
@@ -21148,7 +21147,6 @@ document.addEventListener('DOMContentLoaded', () => {
clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
}
@@ -21163,7 +21161,6 @@ document.addEventListener('DOMContentLoaded', () => {
clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
}
+133 -2
View File
@@ -10,6 +10,7 @@ import { getManualApproval } from "../settings.mjs";
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
import { setMotd } from "../motd.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import queue from "../queue.mjs";
export default (router, tpl) => {
@@ -69,7 +70,9 @@ export default (router, tpl) => {
const page = queuePage(req);
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
const pending = await db`
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
select i.id, i.mime, i.username, i.dest, i.title, i.stamp, i.size, i.width, i.height,
i.original_filename, i.is_album, i.album_count,
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
from "items" i
left join "tags_assign" ta on ta.item_id = i.id
left join "tags" t on t.id = ta.tag_id
@@ -79,12 +82,69 @@ export default (router, tpl) => {
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
`;
// Album pictures for the albums on this page (cover = order_index 0 uses the item's own files)
const albumIds = pending.filter(p => p.is_album).map(p => p.id);
const albumRows = albumIds.length ? await db`
select item_id, id, dest, mime, width, height, order_index
from album_items where item_id = any(${albumIds}::int[])
order by item_id, order_index asc
` : [];
const albumsById = new Map();
for (const r of albumRows) {
if (!albumsById.has(r.item_id)) albumsById.set(r.item_id, []);
albumsById.get(r.item_id).push(r);
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const mediaKind = (mime) => mime === 'video/youtube' ? 'youtube'
: mime?.startsWith('video') ? 'video'
: mime?.startsWith('image') ? 'image'
: mime?.startsWith('audio') ? 'audio'
: mime === 'application/pdf' ? 'pdf' : 'file';
const cards = processQueueItems(pending).map(p => {
const names = p.tags.map(t => t.normalized);
const rating = names.includes('nsfl') ? 'nsfl' : names.includes('nsfw') ? 'nsfw' : names.includes('sfw') ? 'sfw' : 'untagged';
// Main media (cover for albums)
const albumRows = p.is_album ? (albumsById.get(p.id) || []) : [];
const coverRow = albumRows.find(r => r.dest === p.dest) || albumRows.find(r => r.order_index === 0);
const media = [{
index: 1, aid: coverRow ? coverRow.id : null, kind: mediaKind(p.mime), mime: p.mime,
src: `/mod/pending/b/${p.dest}`, thumb: `/mod/pending/t/${p.id}.webp`,
width: p.width, height: p.height
}];
if (p.is_album) {
const subs = albumRows.filter(r => r !== coverRow);
for (const r of subs) {
media.push({
index: media.length + 1, aid: r.id, kind: mediaKind(r.mime), mime: r.mime,
src: `/mod/pending/b/${r.dest}`, thumb: `/mod/pending/t/${r.dest.replace(/\.[^.]+$/, '')}.webp`,
width: r.width, height: r.height
});
}
}
return {
...p,
rating,
kind: mediaKind(p.mime),
media,
media_json: JSON.stringify(media),
is_album_view: media.length > 1,
time_ago: p.stamp ? lib.timeAgo(new Date(p.stamp * 1000), req.lang) : '',
time_full: p.stamp ? new Date(p.stamp * 1000).toISOString() : '',
size_fmt: p.size ? lib.formatSize(p.size) : '',
dims: (p.width && p.height) ? `${p.width}×${p.height}` : ''
};
});
res.reply({
body: tpl.render('mod/approve', {
pending: processQueueItems(pending),
pending: cards,
total,
page,
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
enable_nsfl: !!cfg.enable_nsfl,
nsfl_tag_id: nsflId,
session: req.session,
tmp: null
}, req)
@@ -736,6 +796,77 @@ export default (router, tpl) => {
}
});
// Deny a single picture of a pending album. The rest stays in the queue; if the cover is denied the next
// picture becomes the cover, and with one picture left the album is degraded to a normal single post.
router.post(/^\/mod\/album-item\/deny\/?$/, lib.modAuth, async (req, res) => {
const itemId = +(req.post?.item_id || 0);
const subId = +(req.post?.album_item_id || 0);
const reason = (req.post?.reason || '').toString().trim();
if (!itemId || !subId) return jsonReply(res, 400, { success: false, msg: 'Missing item_id or album_item_id' });
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
const [item] = await db`select id, dest, mime, username, is_album from "items" where id = ${itemId} and active = false and is_deleted = false limit 1`;
if (!item || !item.is_album) return jsonReply(res, 404, { success: false, msg: 'Not a pending album' });
const subs = await db`
select id, dest, mime, size, checksum, phash, width, height, order_index
from album_items where item_id = ${itemId} order by order_index asc, id asc
`;
const target = subs.find(r => r.id === subId);
if (!target) return jsonReply(res, 404, { success: false, msg: 'Album item not found' });
if (subs.length <= 1) return jsonReply(res, 400, { success: false, msg: 'Only one item left, deny the whole post instead' });
const remaining = subs.filter(r => r.id !== subId);
const isCover = target.dest === item.dest;
const degraded = remaining.length === 1;
await db.begin(async sql => {
await sql`delete from album_items where id = ${subId}`;
for (let i = 0; i < remaining.length; i++) {
await sql`update album_items set order_index = ${i} where id = ${remaining[i].id}`;
}
if (isCover) {
const c = remaining[0];
await sql`
update "items" set dest = ${c.dest}, mime = ${c.mime}, size = ${c.size},
checksum = ${c.checksum}, phash = ${c.phash}, width = ${c.width}, height = ${c.height}
where id = ${itemId}
`;
}
if (degraded) {
await sql`delete from album_items where item_id = ${itemId}`;
await sql`update "items" set is_album = false, album_count = 0 where id = ${itemId}`;
} else {
await sql`update "items" set album_count = ${remaining.length} where id = ${itemId}`;
}
});
// Remove the denied picture's files from the pending folders
const base = target.dest.replace(/\.[^.]+$/, '');
await fs.unlink(path.join(cfg.paths.pending, 'b', target.dest)).catch(() => {});
await fs.unlink(path.join(cfg.paths.pending, 't', `${base}.webp`)).catch(() => {});
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${base}.webp`)).catch(() => {});
// New cover: regenerate the item's main + blurred thumbnail from it
if (isCover) {
const c = remaining[0];
try {
await queue.genThumbnail(c.dest, c.mime, itemId, null, true);
await queue.genBlurredThumbnail(itemId, true);
} catch (err) {
console.error('[MOD ALBUM DENY] Thumbnail regeneration failed:', err);
}
}
await audit.log(req.session.id, 'deny_album_item', 'item', itemId, {
filename: target.dest, album_item_id: subId, reason,
remaining: remaining.length, cover_changed: isCover, degraded,
...(await uploaderInfoFor(item.username))
});
return jsonReply(res, 200, { success: true, remaining: remaining.length, cover_changed: isCover, degraded });
});
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
+175 -19
View File
@@ -3,7 +3,7 @@
<div class="pagewrapper">
<div id="main">
@include(snippets/admin-back)
<div class="container mq">
<div class="container mq mq-review">
<header class="mq-header">
<div>
<h1 class="mq-title">Approval Queue</h1>
@@ -15,29 +15,55 @@
@if(pending.length > 0)
<div class="mq-grid">
@each(pending as post)
<div class="mq-card">
<article class="mq-card" data-id="{!! post.id !!}" data-media="{!! post.media_json !!}">
<div class="mq-media">
@if(post.mime === 'video/youtube')
<div class="embed-responsive embed-responsive-16by9">
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
</div>
@elseif(post.mime.startsWith('video'))
<video controls loop muted preload="none" poster="/mod/pending/t/{!! post.id !!}.webp">
<source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}">
</video>
@elseif(post.mime === 'application/pdf')
<div class="embed-responsive embed-responsive-16by9">
<iframe class="embed-responsive-item" src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
</div>
<div class="mq-viewer">
@if(post.kind === 'youtube')
<iframe src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen loading="lazy"></iframe>
@elseif(post.kind === 'video')
<video controls loop muted preload="none" poster="/mod/pending/t/{!! post.id !!}.webp"><source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}"></video>
@elseif(post.kind === 'pdf')
<iframe src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy"></iframe>
@elseif(post.kind === 'audio')
<div class="mq-audio"><img src="/mod/pending/t/{!! post.id !!}.webp" alt="" loading="lazy"><audio controls preload="none" src="/mod/pending/b/{!! post.dest !!}"></audio></div>
@else
<img src="/mod/pending/t/{!! post.id !!}.webp" alt="Preview" loading="lazy">
<img src="/mod/pending/t/{!! post.id !!}.webp" data-full="/mod/pending/b/{!! post.dest !!}" alt="Preview" loading="lazy">
@endif
</div>
<span class="mq-badge mq-badge-rating is-{!! post.rating !!}">{!! post.rating === 'untagged' ? '?' : post.rating.toUpperCase() !!}</span>
<span class="mq-badge mq-badge-kind">@if(post.is_album_view)<i class="fa-solid fa-images"></i> Album · <span class="mq-current">1</span> / {!! post.media.length !!}@else{!! post.kind !!}@endif</span>
</div>
@if(post.is_album_view)
<div class="mq-strip" data-no-swipe>
@each(post.media as m)
<div class="mq-strip-item@if(m.index === 1) is-active@endif" data-index="{!! m.index !!}">
<button type="button" class="mq-strip-select" title="Show item {!! m.index !!}">
<img src="{!! m.thumb !!}" alt="" loading="lazy">
@if(m.kind === 'video')<i class="fa-solid fa-play"></i>@endif
</button>
@if(m.aid)
<button type="button" class="mq-strip-deny" data-item-id="{!! post.id !!}" data-aid="{!! m.aid !!}" title="Deny only this item"><i class="fa-solid fa-xmark"></i></button>
@endif
</div>
@endeach
</div>
@endif
<div class="mq-body">
@if(post.title)
<div class="mq-item-title">{!! post.title !!}</div>
@endif
<div class="mq-meta">
<span><strong>#{!! post.id !!}</strong></span>
<span>by <strong>{!! post.username !!}</strong></span>
<span><i class="fa-solid fa-user"></i> <strong>{!! post.username !!}</strong></span>
@if(post.time_ago)<span tooltip="{!! post.time_full !!}"><i class="fa-regular fa-clock"></i> {!! post.time_ago !!}</span>@endif
</div>
<div class="mq-meta mq-meta-file">
<span>{!! post.mime !!}</span>
@if(post.size_fmt)<span>{!! post.size_fmt !!}</span>@endif
@if(post.dims)<span>{!! post.dims !!}</span>@endif
@if(post.original_filename)<span class="mq-filename" title="{!! post.original_filename !!}">{!! post.original_filename !!}</span>@endif
</div>
<div class="mq-tags">
@each(post.tags as tag)
@@ -45,12 +71,12 @@
@endeach
</div>
<div class="mq-actions">
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-primary btn-approve-async">Approve</button>
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-danger btn-deny-async">Deny</button>
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-primary btn-approve-async"><i class="fa-solid fa-check"></i> Approve</button>
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-danger btn-deny-async"><i class="fa-solid fa-xmark"></i> Deny</button>
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-ghost mq-btn-rating btn-rating-toggle-async"><i class="fa-solid fa-shield-halved"></i> Toggle rating</button>
</div>
</div>
</div>
</article>
@endeach
</div>
@else
@@ -71,6 +97,136 @@
</div>
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}}
@include(snippets/mod-cards)
<style>
/* Review cards (approval queue) */
.mq-review .mq-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
.mq-review .mq-media { aspect-ratio: 4 / 3; }
.mq-review .mq-viewer { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: #000; }
.mq-review .mq-viewer > img, .mq-review .mq-viewer > video, .mq-review .mq-viewer > iframe { width: 100%; height: 100%; object-fit: contain; border: 0; display: block; }
.mq-review .mq-viewer > img[data-full] { cursor: zoom-in; }
.mq-review .mq-audio { width: 100%; height: 100%; display: flex; flex-direction: column; }
.mq-review .mq-audio img { flex: 1; min-height: 0; object-fit: contain; }
.mq-review .mq-audio audio { width: 100%; }
.mq-review .mq-badge {
position: absolute; z-index: 2; padding: 2px 7px; font-size: 0.68em; font-weight: 800;
text-transform: uppercase; letter-spacing: 0.06em; line-height: 1.6; pointer-events: none;
background: rgba(0, 0, 0, 0.75); color: #fff; border: 1px solid rgba(255, 255, 255, 0.15);
}
.mq-review .mq-badge-rating { top: 8px; left: 8px; }
.mq-review .mq-badge-rating.is-sfw { background: var(--badge-sfw, #02500b); }
.mq-review .mq-badge-rating.is-nsfw { background: var(--badge-nsfw, #E10DC3); }
.mq-review .mq-badge-rating.is-nsfl { background: var(--badge-nsfl, #660000); }
.mq-review .mq-badge-rating.is-untagged { background: #555; }
.mq-review .mq-badge-kind { top: 8px; right: 8px; }
.mq-review .mq-badge-kind i { margin-right: 3px; color: var(--mq-accent); }
.mq-review .mq-badge-kind .mq-current { font-variant-numeric: tabular-nums; }
.mq-review .mq-strip {
display: flex; gap: 4px; padding: 6px; overflow-x: auto; overscroll-behavior-x: contain;
background: rgba(0, 0, 0, 0.35); border-top: 1px solid var(--mq-border);
scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.mq-review .mq-strip-item { position: relative; flex: 0 0 56px; height: 56px; }
.mq-review .mq-strip-select {
position: relative; width: 100%; height: 100%; padding: 0; margin: 0; cursor: pointer;
background: #000; border: 1px solid var(--mq-border); opacity: 0.55; transition: opacity 0.15s, border-color 0.15s;
}
.mq-review .mq-strip-select img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mq-review .mq-strip-select i { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 0.8em; text-shadow: 0 1px 3px #000; }
.mq-review .mq-strip-item:hover .mq-strip-select { opacity: 0.85; }
.mq-review .mq-strip-item.is-active .mq-strip-select { opacity: 1; border-color: var(--mq-accent); }
.mq-review .mq-strip-deny {
position: absolute; top: 0; right: 0; width: 20px; height: 20px; padding: 0; margin: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 0.7em;
background: rgba(0, 0, 0, 0.8); color: var(--mq-danger); border: 1px solid rgba(255, 92, 92, 0.5);
opacity: 0; transition: opacity 0.15s, background 0.15s;
}
.mq-review .mq-strip-item:hover .mq-strip-deny, .mq-review .mq-strip-deny:focus-visible { opacity: 1; }
.mq-review .mq-strip-deny:hover { background: var(--mq-danger); color: #000; }
@media (hover: none) { .mq-review .mq-strip-deny { opacity: 1; } }
.mq-review .mq-item-title { font-weight: 700; font-size: 0.95em; overflow-wrap: anywhere; }
.mq-review .mq-meta i { color: var(--mq-muted); margin-right: 2px; }
.mq-review .mq-meta-file { font-size: 0.74em; }
.mq-review .mq-filename { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mq-review .mq-actions .mq-btn { height: 38px; }
</style>
<script>
(() => {
// Album viewer: swap the main viewer to the clicked strip item
const buildViewer = (m) => {
if (m.kind === 'video') {
const v = document.createElement('video');
v.controls = true; v.loop = true; v.muted = true; v.preload = 'metadata'; v.poster = m.thumb;
const src = document.createElement('source'); src.src = m.src; src.type = m.mime; v.appendChild(src);
return v;
}
if (m.kind === 'audio') {
const wrap = document.createElement('div'); wrap.className = 'mq-audio';
const img = document.createElement('img'); img.src = m.thumb; img.alt = '';
const a = document.createElement('audio'); a.controls = true; a.src = m.src;
wrap.append(img, a); return wrap;
}
if (m.kind === 'pdf') {
const f = document.createElement('iframe'); f.src = m.src + '#toolbar=0'; return f;
}
const img = document.createElement('img');
img.src = m.kind === 'image' ? m.src : m.thumb; img.alt = 'Preview'; img.dataset.full = m.src;
return img;
};
document.querySelectorAll('.mq-review .mq-card').forEach(card => {
let media = [];
try { media = JSON.parse(card.dataset.media || '[]'); } catch (e) {}
const viewer = card.querySelector('.mq-viewer');
const current = card.querySelector('.mq-current');
card.querySelectorAll('.mq-strip-item').forEach(itemEl => {
itemEl.querySelector('.mq-strip-select')?.addEventListener('click', () => {
const m = media.find(x => String(x.index) === itemEl.dataset.index);
if (!m || !viewer) return;
viewer.querySelectorAll('video, audio').forEach(el => { try { el.pause(); } catch (e) {} });
viewer.replaceChildren(buildViewer(m));
card.querySelectorAll('.mq-strip-item').forEach(b => b.classList.toggle('is-active', b === itemEl));
if (current) current.textContent = m.index;
});
});
// Deny a single album item (the rest stays; one item left → becomes a single post)
card.querySelectorAll('.mq-strip-deny').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const left = card.querySelectorAll('.mq-strip-item').length - 1;
mq.confirm({
heading: 'Deny album item',
body: left <= 1
? 'Deny only this item? The album will become a single post with the remaining item.'
: 'Deny only this item? ' + left + ' items stay in the album.',
confirmLabel: 'Deny item',
withReason: true,
action: async (reason) => {
await mq.post('/mod/album-item/deny', { item_id: btn.dataset.itemId, album_item_id: btn.dataset.aid, reason });
mq.flash(left <= 1 ? 'Item denied, album is now a single post' : 'Album item denied', 'success');
const here = location.pathname + location.search;
if (window.loadPageAjax) window.loadPageAjax(here, true, { skipCache: true });
else location.reload();
}
});
});
});
// Click a still image to open the full file
if (viewer) viewer.addEventListener('click', e => {
const img = e.target.closest('img[data-full]');
if (img) window.open(img.dataset.full, '_blank', 'noopener');
});
// Keep the rating badge in sync after "Toggle rating" (tags are re-rendered by mod-cards)
const badge = card.querySelector('.mq-badge-rating');
const tags = card.querySelector('.mq-tags');
if (badge && tags) new MutationObserver(() => {
const names = Array.from(tags.querySelectorAll('.badge')).map(b => b.textContent.trim().toLowerCase());
const r = names.includes('nsfl') ? 'nsfl' : names.includes('nsfw') ? 'nsfw' : names.includes('sfw') ? 'sfw' : 'untagged';
badge.className = 'mq-badge mq-badge-rating is-' + r;
badge.textContent = r === 'untagged' ? '?' : r.toUpperCase();
}).observe(tags, { childList: true });
});
})();
</script>
<script>
(() => {
// Approve → public