hgfd
This commit is contained in:
@@ -3977,6 +3977,16 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
|||||||
color: #fff;
|
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 ── */
|
/* ── Tuner: style guide (square, flat, theme colours, no glow) + touch-friendly sliders ── */
|
||||||
.f0ck-audio-tuner-panel,
|
.f0ck-audio-tuner-panel,
|
||||||
.f0ck-audio-tuner-panel *,
|
.f0ck-audio-tuner-panel *,
|
||||||
|
|||||||
@@ -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>`;
|
: `<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 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
|
const authorHtml = isAnon
|
||||||
? `<span class="comment-author">anonymous</span>${anonTripcodeHtml}`
|
? `<span class="comment-author">anonymous</span>`
|
||||||
: (comment.username
|
: (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>`
|
? `<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>');
|
: '<span class="comment-author">System</span>');
|
||||||
|
|||||||
@@ -21133,7 +21133,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearReportError();
|
clearReportError();
|
||||||
reportModal.style.display = 'flex';
|
reportModal.style.display = 'flex';
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
|
|
||||||
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
|
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21148,7 +21147,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearReportError();
|
clearReportError();
|
||||||
reportModal.style.display = 'flex';
|
reportModal.style.display = 'flex';
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
|
|
||||||
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
|
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21163,7 +21161,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearReportError();
|
clearReportError();
|
||||||
reportModal.style.display = 'flex';
|
reportModal.style.display = 'flex';
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
setTimeout(() => { try { reportReason.focus(); } catch(err){} }, 50);
|
|
||||||
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
|
if (window.renderReportRecaptcha) window.renderReportRecaptcha();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+133
-2
@@ -10,6 +10,7 @@ import { getManualApproval } from "../settings.mjs";
|
|||||||
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
|
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
|
||||||
import { setMotd } from "../motd.mjs";
|
import { setMotd } from "../motd.mjs";
|
||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
|
import queue from "../queue.mjs";
|
||||||
|
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
@@ -69,7 +70,9 @@ export default (router, tpl) => {
|
|||||||
const page = queuePage(req);
|
const page = queuePage(req);
|
||||||
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
|
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
|
||||||
const pending = await db`
|
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
|
from "items" i
|
||||||
left join "tags_assign" ta on ta.item_id = i.id
|
left join "tags_assign" ta on ta.item_id = i.id
|
||||||
left join "tags" t on t.id = ta.tag_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}
|
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({
|
res.reply({
|
||||||
body: tpl.render('mod/approve', {
|
body: tpl.render('mod/approve', {
|
||||||
pending: processQueueItems(pending),
|
pending: cards,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
|
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
|
||||||
|
enable_nsfl: !!cfg.enable_nsfl,
|
||||||
|
nsfl_tag_id: nsflId,
|
||||||
session: req.session,
|
session: req.session,
|
||||||
tmp: null
|
tmp: null
|
||||||
}, req)
|
}, 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) ──
|
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
|
||||||
|
|
||||||
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
|
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
|
||||||
|
|||||||
+175
-19
@@ -3,7 +3,7 @@
|
|||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
@include(snippets/admin-back)
|
@include(snippets/admin-back)
|
||||||
<div class="container mq">
|
<div class="container mq mq-review">
|
||||||
<header class="mq-header">
|
<header class="mq-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="mq-title">Approval Queue</h1>
|
<h1 class="mq-title">Approval Queue</h1>
|
||||||
@@ -15,29 +15,55 @@
|
|||||||
@if(pending.length > 0)
|
@if(pending.length > 0)
|
||||||
<div class="mq-grid">
|
<div class="mq-grid">
|
||||||
@each(pending as post)
|
@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">
|
<div class="mq-media">
|
||||||
@if(post.mime === 'video/youtube')
|
<div class="mq-viewer">
|
||||||
<div class="embed-responsive embed-responsive-16by9">
|
@if(post.kind === 'youtube')
|
||||||
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
|
<iframe src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen loading="lazy"></iframe>
|
||||||
</div>
|
@elseif(post.kind === 'video')
|
||||||
@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>
|
||||||
<video controls loop muted preload="none" poster="/mod/pending/t/{!! post.id !!}.webp">
|
@elseif(post.kind === 'pdf')
|
||||||
<source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}">
|
<iframe src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy"></iframe>
|
||||||
</video>
|
@elseif(post.kind === 'audio')
|
||||||
@elseif(post.mime === 'application/pdf')
|
<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>
|
||||||
<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>
|
|
||||||
@else
|
@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
|
@endif
|
||||||
</div>
|
</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">
|
<div class="mq-body">
|
||||||
|
@if(post.title)
|
||||||
|
<div class="mq-item-title">{!! post.title !!}</div>
|
||||||
|
@endif
|
||||||
<div class="mq-meta">
|
<div class="mq-meta">
|
||||||
<span><strong>#{!! post.id !!}</strong></span>
|
<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>
|
<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>
|
||||||
<div class="mq-tags">
|
<div class="mq-tags">
|
||||||
@each(post.tags as tag)
|
@each(post.tags as tag)
|
||||||
@@ -45,12 +71,12 @@
|
|||||||
@endeach
|
@endeach
|
||||||
</div>
|
</div>
|
||||||
<div class="mq-actions">
|
<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-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">Deny</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>
|
<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>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
@endeach
|
@endeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
@@ -71,6 +97,136 @@
|
|||||||
</div>
|
</div>
|
||||||
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}}
|
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}}
|
||||||
@include(snippets/mod-cards)
|
@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>
|
<script>
|
||||||
(() => {
|
(() => {
|
||||||
// Approve → public
|
// Approve → public
|
||||||
|
|||||||
Reference in New Issue
Block a user