add nsfp tag manager
This commit is contained in:
@@ -13354,7 +13354,6 @@ i.iconset#a_oc {
|
||||
margin-bottom: 35px;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@@ -15779,4 +15778,127 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
.notif-thumb.notif-thumb-blurred.revealed::after {
|
||||
opacity: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import queue from "../queue.mjs";
|
||||
import fs from "fs";
|
||||
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)
|
||||
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
|
||||
@@ -251,7 +251,7 @@ export default {
|
||||
${mimeSQL}
|
||||
${hallFilter}
|
||||
${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``}
|
||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||
${xdFilter}
|
||||
@@ -293,7 +293,7 @@ export default {
|
||||
${mimeSQL}
|
||||
${hallFilter}
|
||||
${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``}
|
||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||
${xdFilter}
|
||||
@@ -515,7 +515,7 @@ export default {
|
||||
${fav ? db`and "user"."user" ilike ${user}` : db``}
|
||||
${!fav && user ? db`and items.username ilike ${user}` : db``}
|
||||
${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``}
|
||||
`;
|
||||
};
|
||||
@@ -548,7 +548,7 @@ export default {
|
||||
where
|
||||
items.id = ${itemid} and
|
||||
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
|
||||
`;
|
||||
|
||||
@@ -566,7 +566,7 @@ export default {
|
||||
|
||||
if (!actitem) {
|
||||
// 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`
|
||||
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 IS NOT NULL
|
||||
${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``}
|
||||
ORDER BY random()
|
||||
LIMIT 1
|
||||
@@ -937,7 +937,7 @@ export default {
|
||||
and "user".user ilike ${user}
|
||||
and items.active = true
|
||||
${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
|
||||
order by random()
|
||||
limit 1
|
||||
@@ -979,7 +979,7 @@ export default {
|
||||
${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``}
|
||||
${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``}
|
||||
group by items.id, tags.tag
|
||||
order by random()
|
||||
@@ -998,7 +998,7 @@ export default {
|
||||
and h.slug = ${hall}
|
||||
and items.active = true
|
||||
${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``}
|
||||
order by random()
|
||||
limit 1
|
||||
|
||||
@@ -10,7 +10,7 @@ import audit from '../../audit.mjs';
|
||||
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
|
||||
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 MAX_META_CACHE = 2000;
|
||||
|
||||
|
||||
126
src/inc/routes/nsfp.mjs
Normal file
126
src/inc/routes/nsfp.mjs
Normal 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;
|
||||
};
|
||||
@@ -89,3 +89,13 @@ export const setHashUserIps = (val) => {}; // No-op, strictly config-based
|
||||
export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion;
|
||||
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];
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_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 { createI18n } from "./inc/i18n.mjs";
|
||||
import security from "./inc/security.mjs";
|
||||
@@ -1087,6 +1087,22 @@ process.on('uncaughtException', err => {
|
||||
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 = {
|
||||
lul: cfg.websrv.lul,
|
||||
themes: cfg.websrv.themes,
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<li><a href="/admin/halls">Hall 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/nsfp">NSFP Tag Manager</a></li>
|
||||
@if(enable_cleanup)
|
||||
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
|
||||
@endif
|
||||
|
||||
254
views/admin/nsfp.html
Normal file
254
views/admin/nsfp.html
Normal 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> — 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…</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user