From b96f188a59b14c35567e7cf26adbe3e463af3008 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Mon, 28 Sep 2026 20:40:33 +0200 Subject: [PATCH] hgfd --- public/s/css/f0ckm.css | 10 ++ public/s/js/comments.js | 8 +- public/s/js/f0ckm.js | 3 - src/inc/routes/mod.mjs | 135 ++++++++++++++++++++++++++- views/mod/approve.html | 196 ++++++++++++++++++++++++++++++++++++---- 5 files changed, 320 insertions(+), 32 deletions(-) diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 9527ce7..de86180 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -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 *, diff --git a/public/s/js/comments.js b/public/s/js/comments.js index a9a7387..c075191 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -2274,14 +2274,8 @@ class CommentSystem { : `
${comment.username ? `` : ''}${comment.username ? `` : ''}
`; 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 - ? ` [${this.escapeHtml(anonShortFp)}]` - : ''; - const authorHtml = isAnon - ? `anonymous${anonTripcodeHtml}` + ? `anonymous` : (comment.username ? `${this.escapeHtml(comment.display_name || comment.username)}` : 'System'); diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index be3c07d..a2537d9 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -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(); } diff --git a/src/inc/routes/mod.mjs b/src/inc/routes/mod.mjs index cbf37bc..0af72b3 100644 --- a/src/inc/routes/mod.mjs +++ b/src/inc/routes/mod.mjs @@ -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 diff --git a/views/mod/approve.html b/views/mod/approve.html index 7f824e1..f5c0caf 100644 --- a/views/mod/approve.html +++ b/views/mod/approve.html @@ -3,7 +3,7 @@
@include(snippets/admin-back) -
+

Approval Queue

@@ -15,29 +15,55 @@ @if(pending.length > 0)
@each(pending as post) -
+
- @if(post.mime === 'video/youtube') -
- +
+ @if(post.kind === 'youtube') + + @elseif(post.kind === 'video') + + @elseif(post.kind === 'pdf') + + @elseif(post.kind === 'audio') +
+ @else + Preview + @endif
- @elseif(post.mime.startsWith('video')) - - @elseif(post.mime === 'application/pdf') -
- -
- @else - Preview - @endif + {!! post.rating === 'untagged' ? '?' : post.rating.toUpperCase() !!} + @if(post.is_album_view) Album · 1 / {!! post.media.length !!}@else{!! post.kind !!}@endif
+ + @if(post.is_album_view) +
+ @each(post.media as m) +
+ + @if(m.aid) + + @endif +
+ @endeach +
+ @endif +
+ @if(post.title) +
{!! post.title !!}
+ @endif
#{!! post.id !!} - by {!! post.username !!} + {!! post.username !!} + @if(post.time_ago) {!! post.time_ago !!}@endif +
+
{!! post.mime !!} + @if(post.size_fmt){!! post.size_fmt !!}@endif + @if(post.dims){!! post.dims !!}@endif + @if(post.original_filename){!! post.original_filename !!}@endif
@each(post.tags as tag) @@ -45,12 +71,12 @@ @endeach
- - + +
-
+
@endeach
@else @@ -71,6 +97,136 @@
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}} @include(snippets/mod-cards) + +