add nsfp tag manager

This commit is contained in:
2026-06-19 14:44:56 +02:00
parent 06564af203
commit 8a24564cd9
8 changed files with 543 additions and 14 deletions

View File

@@ -13354,7 +13354,6 @@ i.iconset#a_oc {
margin-bottom: 35px; margin-bottom: 35px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 15px; gap: 15px;
padding: 0 10px;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -15780,3 +15779,126 @@ body.scroller-active #gchat-reopen-bubble {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
/* ── NSFP Tag Manager ─────────────────────────────────────────────────────── */
.nsfp-tag-chip {
display: inline-flex;
align-items: center;
gap: 8px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: 20px;
padding: 6px 14px 6px 12px;
font-size: 0.88em;
font-weight: 600;
color: color-mix(in srgb, var(--accent) 70%, #fff);
transition: background 0.2s, border-color 0.2s;
}
.nsfp-tag-chip:hover {
background: color-mix(in srgb, var(--accent) 20%, transparent);
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
}
.nsfp-tag-chip .chip-id {
font-size: 0.78em;
opacity: 0.6;
font-weight: 400;
font-family: monospace;
}
.nsfp-tag-chip .chip-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0 0 0 2px;
font-size: 1.1em;
line-height: 1;
opacity: 0.7;
transition: opacity 0.15s;
}
.nsfp-tag-chip .chip-remove:hover {
opacity: 1;
color: #ff4090;
}
.nsfp-chips-area {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 44px;
padding: 10px;
background: rgba(0,0,0,0.2);
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.06);
margin-top: 12px;
}
.nsfp-chips-empty {
color: #888;
font-size: 0.9em;
font-style: italic;
align-self: center;
margin: 0 auto;
}
.nsfp-search-wrap { position: relative; }
.nsfp-search-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
width: 100%;
max-height: 260px;
overflow-y: auto;
background: var(--dropdown-bg, #1a1a1a);
border: 1px solid var(--black, #000);
border-radius: 6px;
box-shadow: 0 6px 20px rgba(0,0,0,0.6);
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-color, #555) transparent;
}
.nsfp-search-input {
width: 100%;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
padding: 10px 14px;
border-radius: 6px;
font-size: 1em;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.nsfp-search-input:focus { border-color: color-mix(in srgb, var(--accent) 50%, transparent); }
.nsfp-search-input::placeholder { color: rgba(255,255,255,0.35); }
.nsfp-search-result-item {
display: flex;
align-items: center;
padding: 10px 12px;
min-height: 44px;
cursor: pointer;
transition: background 0.12s;
box-sizing: border-box;
user-select: none;
}
.nsfp-search-result-item:not(:last-child) { border-bottom: 1px solid rgba(255,255,255,0.06); }
.nsfp-search-result-item:hover { background: rgba(255,255,255,0.1); }
.nsfp-search-result-item .result-id {
font-family: monospace;
font-size: 11px;
color: rgba(255,255,255,0.35);
flex-shrink: 0;
margin-right: 4px;
}
.nsfp-search-result-item .result-tag { font-size: 14px; font-weight: 500; color: var(--accent); }
.nsfp-search-result-item .result-norm { font-size: 11px; color: rgba(255,255,255,0.35); margin-left: 12px; white-space: nowrap; }
.nsfp-search-result-item.already-added { opacity: 0.55; cursor: default; }
.nsfp-search-result-item.already-added::after {
content: 'added';
font-size: 11px;
color: rgba(255,255,255,0.35);
margin-left: auto;
white-space: nowrap;
}
.nsfp-status-msg {
font-size: 0.85em;
font-weight: 600;
margin-top: 8px;
min-height: 18px;
display: block;
}

View File

@@ -6,7 +6,7 @@ import queue from "../queue.mjs";
import fs from "fs"; import fs from "fs";
import url from "url"; import url from "url";
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null; const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null;
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie) // All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime); const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
@@ -251,7 +251,7 @@ export default {
${mimeSQL} ${mimeSQL}
${hallFilter} ${hallFilter}
${userHallFilter} ${userHallFilter}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``} ${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter} ${xdFilter}
@@ -293,7 +293,7 @@ export default {
${mimeSQL} ${mimeSQL}
${hallFilter} ${hallFilter}
${userHallFilter} ${userHallFilter}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``} ${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter} ${xdFilter}
@@ -515,7 +515,7 @@ export default {
${fav ? db`and "user"."user" ilike ${user}` : db``} ${fav ? db`and "user"."user" ilike ${user}` : db``}
${!fav && user ? db`and items.username ilike ${user}` : db``} ${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL} ${mimeSQL}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
`; `;
}; };
@@ -548,7 +548,7 @@ export default {
where where
items.id = ${itemid} and items.id = ${itemid} and
items.active = true items.active = true
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
limit 1 limit 1
`; `;
@@ -566,7 +566,7 @@ export default {
if (!actitem) { if (!actitem) {
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags) // Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
if (!session && globalfilter) { if (!session && getGlobalfilter()) {
const unfilteredItem = await db` const unfilteredItem = await db`
select id from items where id = ${itemid} and active = true limit 1 select id from items where id = ${itemid} and active = true limit 1
`; `;
@@ -917,7 +917,7 @@ export default {
AND items.title ILIKE ${'%' + titleQuery + '%'} AND items.title ILIKE ${'%' + titleQuery + '%'}
AND items.title IS NOT NULL AND items.title IS NOT NULL
${mimeSQL} ${mimeSQL}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
ORDER BY random() ORDER BY random()
LIMIT 1 LIMIT 1
@@ -937,7 +937,7 @@ export default {
and "user".user ilike ${user} and "user".user ilike ${user}
and items.active = true and items.active = true
${mimeSQL} ${mimeSQL}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
group by items.id group by items.id
order by random() order by random()
limit 1 limit 1
@@ -979,7 +979,7 @@ export default {
${user ? db`and items.username ilike ${user}` : db``} ${user ? db`and items.username ilike ${user}` : db``}
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``} ${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
${mimeSQL} ${mimeSQL}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
group by items.id, tags.tag group by items.id, tags.tag
order by random() order by random()
@@ -998,7 +998,7 @@ export default {
and h.slug = ${hall} and h.slug = ${hall}
and items.active = true and items.active = true
${mimeSQL} ${mimeSQL}
${!session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
order by random() order by random()
limit 1 limit 1

View File

@@ -10,7 +10,7 @@ import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs'; import { parseMultipart, collectBody } from '../../multipart.mjs';
const allowedMimes = ["audio", "image", "video", "%"]; const allowedMimes = ["audio", "image", "video", "%"];
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null; const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
const metaCache = new Map(); const metaCache = new Map();
const MAX_META_CACHE = 2000; const MAX_META_CACHE = 2000;

126
src/inc/routes/nsfp.mjs Normal file
View File

@@ -0,0 +1,126 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { getNsfpIds, setNsfpIds } from "../settings.mjs";
export default (router, tpl) => {
// Admin page
router.get(/^\/admin\/nsfp\/?$/, lib.auth, async (req, res) => {
try {
res.reply({
body: tpl.render("admin/nsfp", {
session: req.session,
totals: await lib.countf0cks(),
csrf_token: req.session?.csrf_token || ''
}, req)
});
} catch (err) {
console.error('[NSFP] Page render failed:', err);
res.reply({ code: 500, body: 'Internal server error' });
}
});
// API: list current NSFP tag IDs with names
router.get('/api/v2/admin/nsfp', lib.auth, async (req, res) => {
try {
const ids = getNsfpIds();
const tagRows = ids.length > 0
? await db`SELECT id, tag, normalized FROM tags WHERE id IN ${db(ids)} ORDER BY id`
: [];
const tagMap = Object.fromEntries(tagRows.map(r => [r.id, r]));
const enriched = ids.map(id => tagMap[id] || { id, tag: '(unknown)', normalized: null });
return res.json({ success: true, nsfp: enriched, raw_ids: ids });
} catch (err) {
return res.json({ success: false, msg: err.message }, 500);
}
});
// API: search tags for the add-tag autocomplete
router.get('/api/v2/admin/nsfp/search', lib.auth, async (req, res) => {
try {
const q = (req.url.qs?.q || '').trim();
if (!q) return res.json({ success: true, tags: [] });
const pattern = '%' + q + '%';
const tags = await db`
SELECT id, tag, normalized
FROM tags
WHERE tag ILIKE ${pattern} OR normalized ILIKE ${pattern}
ORDER BY tag
LIMIT 20
`;
return res.json({ success: true, tags });
} catch (err) {
return res.json({ success: false, msg: err.message }, 500);
}
});
// API: add a tag ID to the NSFP list
router.post('/api/v2/admin/nsfp/add', lib.auth, async (req, res) => {
try {
const tagId = parseInt(req.post?.tag_id);
if (!tagId || isNaN(tagId) || tagId <= 0) {
return res.json({ success: false, msg: 'A valid tag_id is required' }, 400);
}
const tag = await db`SELECT id, tag FROM tags WHERE id = ${tagId} LIMIT 1`;
if (tag.length === 0) {
return res.json({ success: false, msg: 'Tag with id ' + tagId + ' does not exist' }, 404);
}
const current = getNsfpIds();
if (current.includes(tagId)) {
return res.json({ success: false, msg: 'Tag #' + tagId + ' (' + tag[0].tag + ') is already in the NSFP list' }, 409);
}
const updated = [...current, tagId];
setNsfpIds(updated);
await db`
INSERT INTO site_settings (key, value)
VALUES ('nsfp', ${JSON.stringify(updated)})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
await audit.log(req.session.id, 'nsfp_add', 'tag', tagId, { tag: tag[0].tag, nsfp: updated });
return res.json({ success: true, nsfp_ids: getNsfpIds(), added: tag[0] });
} catch (err) {
console.error('[NSFP] Add failed:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
// API: remove a tag ID from the NSFP list
router.post('/api/v2/admin/nsfp/remove', lib.auth, async (req, res) => {
try {
const tagId = parseInt(req.post?.tag_id);
if (!tagId || isNaN(tagId)) {
return res.json({ success: false, msg: 'tag_id is required' }, 400);
}
const current = getNsfpIds();
if (!current.includes(tagId)) {
return res.json({ success: false, msg: 'Tag #' + tagId + ' is not in the NSFP list' }, 404);
}
const updated = current.filter(id => id !== tagId);
setNsfpIds(updated);
await db`
INSERT INTO site_settings (key, value)
VALUES ('nsfp', ${JSON.stringify(updated)})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
await audit.log(req.session.id, 'nsfp_remove', 'tag', tagId, { nsfp: updated });
return res.json({ success: true, nsfp_ids: getNsfpIds() });
} catch (err) {
console.error('[NSFP] Remove failed:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
return router;
};

View File

@@ -89,3 +89,13 @@ export const setHashUserIps = (val) => {}; // No-op, strictly config-based
export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion; export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion;
export const setAllowCommentDeletion = (val) => {}; // No-op, strictly config-based export const setAllowCommentDeletion = (val) => {}; // No-op, strictly config-based
// Live-editable NSFP tag ID list — seeded from config.json, can be overridden by DB setting
let nsfp_ids = Array.isArray(cfg.nsfp) ? [...cfg.nsfp.map(Number).filter(n => !isNaN(n))] : [];
export const getNsfpIds = () => nsfp_ids;
export const setNsfpIds = (ids) => {
nsfp_ids = Array.isArray(ids) ? ids.map(Number).filter(n => !isNaN(n) && n > 0) : [];
// Also sync to cfg.nsfp so all code reading cfg.nsfp directly still works
cfg.nsfp = [...nsfp_ids];
};

View File

@@ -18,7 +18,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion } from "./inc/settings.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs"; import { createI18n } from "./inc/i18n.mjs";
import security from "./inc/security.mjs"; import security from "./inc/security.mjs";
@@ -1087,6 +1087,22 @@ process.on('uncaughtException', err => {
console.warn(`[BOOT] Terms text fetch failed:`, e.message); console.warn(`[BOOT] Terms text fetch failed:`, e.message);
} }
// Fetch nsfp (NSFP tag IDs) setting — overrides config.json if set in DB
try {
const nsfpSetting = await db`SELECT value FROM site_settings WHERE key = 'nsfp' LIMIT 1`;
if (nsfpSetting.length > 0) {
const parsed = JSON.parse(nsfpSetting[0].value);
if (Array.isArray(parsed)) {
setNsfpIds(parsed);
console.log(`[BOOT] NSFP tag IDs loaded from DB: [${getNsfpIds().join(', ')}]`);
}
} else {
console.log(`[BOOT] No NSFP setting in DB, using config.json: [${getNsfpIds().join(', ')}]`);
}
} catch (e) {
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
}
const globals = { const globals = {
lul: cfg.websrv.lul, lul: cfg.websrv.lul,
themes: cfg.websrv.themes, themes: cfg.websrv.themes,

View File

@@ -21,6 +21,7 @@
<li><a href="/admin/halls">Hall Manager</a></li> <li><a href="/admin/halls">Hall Manager</a></li>
<li><a href="/admin/motd">MOTD Manager</a></li> <li><a href="/admin/motd">MOTD Manager</a></li>
<li><a href="/admin/wordfilter">Wordfilter Manager</a></li> <li><a href="/admin/wordfilter">Wordfilter Manager</a></li>
<li><a href="/admin/nsfp">NSFP Tag Manager</a></li>
@if(enable_cleanup) @if(enable_cleanup)
<li><a href="/admin/cleanup">Cleanup Manager</a></li> <li><a href="/admin/cleanup">Cleanup Manager</a></li>
@endif @endif

254
views/admin/nsfp.html Normal file
View File

@@ -0,0 +1,254 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<h2 style="margin: 0;">NSFP Tag Manager</h2>
</div>
<p style="color: #aaa; margin-top: 4px; margin-bottom: 20px; font-size: 0.9em;">
Manage which tags are treated as <strong style="color: var(--accent);">Not Safe For Public</strong> &mdash; hidden from logged-out guests.
</p>
<!-- Current NSFP tags -->
<div style="background: rgba(0,0,0,0.15); border: 1px solid rgba(255,255,255,0.06); border-radius: 6px; padding: 20px; margin-bottom: 24px;">
<p style="font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.08em; color: #888; margin: 0 0 8px;">Current NSFP Tags</p>
<div class="nsfp-chips-area" id="nsfp-chips">
<span class="nsfp-chips-empty">Loading&hellip;</span>
</div>
<span id="chip-status" class="nsfp-status-msg"></span>
</div>
<!-- Add tag by search -->
<div style="background: rgba(0,0,0,0.2); padding: 20px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.05);">
<h4 style="color: var(--accent); margin-top: 0; margin-bottom: 14px;">Add Tag to NSFP List</h4>
<div class="nsfp-search-wrap">
<input type="text" id="nsfp-search" class="nsfp-search-input" placeholder="Search tags..." autocomplete="off">
<div class="nsfp-search-dropdown" id="search-dropdown" style="display:none;"></div>
</div>
<span id="add-status" class="nsfp-status-msg"></span>
</div>
</div>
</div>
</div>
<script>
(function () {
var csrf = '{{ csrf_token }}';
var currentIds = new Set();
var chipsEl = document.getElementById('nsfp-chips');
var chipStatus = document.getElementById('chip-status');
var addStatus = document.getElementById('add-status');
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function showStatus(el, msg, ok) {
el.textContent = msg;
el.style.color = ok ? '#28a745' : '#d9534f';
if (ok) setTimeout(function () { el.textContent = ''; }, 3000);
}
function refreshEmptyMsg() {
var existing = chipsEl.querySelector('.nsfp-tag-chip');
var emptyEl = chipsEl.querySelector('.nsfp-chips-empty');
if (!existing) {
if (!emptyEl) {
emptyEl = document.createElement('span');
emptyEl.className = 'nsfp-chips-empty';
emptyEl.textContent = 'No NSFP tags configured \u2014 all content is public.';
chipsEl.appendChild(emptyEl);
}
} else if (emptyEl) {
emptyEl.remove();
}
}
function createChip(tag) {
var chip = document.createElement('span');
chip.className = 'nsfp-tag-chip';
chip.dataset.id = tag.id;
var idSpan = document.createElement('span');
idSpan.className = 'chip-id';
idSpan.textContent = '#' + tag.id;
var nameSpan = document.createElement('span');
nameSpan.textContent = tag.tag;
var removeBtn = document.createElement('button');
removeBtn.className = 'chip-remove';
removeBtn.title = 'Remove from NSFP list';
removeBtn.textContent = '\u00d7';
removeBtn.addEventListener('click', function () { nsfpAdmin.remove(tag.id, removeBtn); });
chip.appendChild(idSpan);
chip.appendChild(nameSpan);
chip.appendChild(removeBtn);
return chip;
}
function addChip(tag) {
var emptyEl = chipsEl.querySelector('.nsfp-chips-empty');
if (emptyEl) emptyEl.remove();
chipsEl.appendChild(createChip(tag));
currentIds.add(tag.id);
rebuildDropdown(document.getElementById('nsfp-search').value);
}
function removeChip(tagId) {
var chip = chipsEl.querySelector('.nsfp-tag-chip[data-id="' + tagId + '"]');
if (chip) chip.remove();
currentIds.delete(tagId);
refreshEmptyMsg();
rebuildDropdown(document.getElementById('nsfp-search').value);
}
function loadNsfp() {
fetch('/api/v2/admin/nsfp')
.then(function (r) { return r.json(); })
.then(function (data) {
chipsEl.innerHTML = '';
currentIds.clear();
if (data.success && data.nsfp && data.nsfp.length > 0) {
data.nsfp.forEach(function (tag) {
chipsEl.appendChild(createChip(tag));
currentIds.add(tag.id);
});
} else {
var emptyEl = document.createElement('span');
emptyEl.className = 'nsfp-chips-empty';
emptyEl.textContent = 'No NSFP tags configured \u2014 all content is public.';
chipsEl.appendChild(emptyEl);
}
})
.catch(function (e) {
chipsEl.innerHTML = '<span class="nsfp-chips-empty" style="color:#d9534f;">Failed to load: ' + escapeHtml(e.message) + '</span>';
});
}
var remove = function (tagId, btn) {
if (!confirm('Remove tag #' + tagId + ' from the NSFP list?')) return;
if (btn) btn.disabled = true;
fetch('/api/v2/admin/nsfp/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf },
body: 'tag_id=' + encodeURIComponent(tagId)
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
removeChip(tagId);
showStatus(chipStatus, 'Tag #' + tagId + ' removed.', true);
} else {
showStatus(chipStatus, 'Error: ' + (data.msg || 'Remove failed'), false);
if (btn) btn.disabled = false;
}
})
.catch(function (e) {
showStatus(chipStatus, 'Network error: ' + e.message, false);
if (btn) btn.disabled = false;
});
};
var doAdd = function (tagId) {
tagId = parseInt(tagId, 10);
if (currentIds.has(tagId)) {
showStatus(addStatus, 'Tag #' + tagId + ' is already in the list.', false);
return;
}
fetch('/api/v2/admin/nsfp/add', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf },
body: 'tag_id=' + encodeURIComponent(tagId)
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) {
addChip(data.added);
showStatus(addStatus, '"' + escapeHtml(data.added.tag) + '" (#' + data.added.id + ') added.', true);
document.getElementById('nsfp-search').value = '';
document.getElementById('search-dropdown').style.display = 'none';
lastResults = [];
} else {
showStatus(addStatus, 'Error: ' + (data.msg || 'Add failed'), false);
}
})
.catch(function (e) { showStatus(addStatus, 'Network error: ' + e.message, false); });
};
var lastResults = [];
function rebuildDropdown(query) {
var dropdown = document.getElementById('search-dropdown');
if (!lastResults.length || !query) { dropdown.style.display = 'none'; return; }
dropdown.innerHTML = '';
lastResults.forEach(function (tag) {
var already = currentIds.has(tag.id);
var item = document.createElement('div');
item.className = 'nsfp-search-result-item' + (already ? ' already-added' : '');
item.dataset.id = tag.id;
var idSpan = document.createElement('span');
idSpan.className = 'result-id';
idSpan.textContent = '#' + tag.id;
var nameSpan = document.createElement('span');
nameSpan.className = 'result-tag';
nameSpan.textContent = tag.tag;
item.appendChild(idSpan);
item.appendChild(nameSpan);
if (tag.normalized && tag.normalized !== tag.tag) {
var normSpan = document.createElement('span');
normSpan.className = 'result-norm';
normSpan.textContent = '(' + tag.normalized + ')';
item.appendChild(normSpan);
}
if (!already) {
item.addEventListener('click', function () { doAdd(tag.id); });
}
dropdown.appendChild(item);
});
dropdown.style.display = 'block';
}
var searchTimer = null;
document.getElementById('nsfp-search').addEventListener('input', function () {
clearTimeout(searchTimer);
var q = this.value.trim();
if (!q) { lastResults = []; document.getElementById('search-dropdown').style.display = 'none'; return; }
var self = this;
searchTimer = setTimeout(function () {
fetch('/api/v2/admin/nsfp/search?q=' + encodeURIComponent(q))
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.success) { lastResults = data.tags; rebuildDropdown(self.value.trim()); }
})
.catch(function () {});
}, 250);
});
document.addEventListener('click', function (e) {
if (!e.target.closest('.nsfp-search-wrap')) {
document.getElementById('search-dropdown').style.display = 'none';
}
});
window.nsfpAdmin = { remove: remove, doAdd: doAdd };
loadNsfp();
})();
</script>
@include(snippets/footer)