8 Commits
98 ... alter

Author SHA1 Message Date
162f1b40d0 pickle rick 2026-08-07 22:38:51 +02:00
9d2b0137da f 2026-08-07 22:35:56 +02:00
e179eb1955 create koepfe 2026-08-07 22:33:52 +02:00
a80e6dab96 jfh 2026-08-07 22:22:51 +02:00
c22ae5bde0 pisse aus meinem arsch 2026-08-07 22:18:32 +02:00
c9f5dc02fa hgdf 2026-08-07 22:14:23 +02:00
4fd15ec338 g 2026-08-07 22:08:53 +02:00
feb408338a 69 2026-08-07 21:42:12 +02:00
50 changed files with 1130 additions and 338 deletions

View File

@@ -29,6 +29,10 @@
],
"enable_pdf": false,
"enable_nsfl": false,
"enable_private_uploads": true,
"default_upload_visibility": 0,
"allow_user_upload_visibility": true,
"enable_item_slugs": true,
"nsfl_tag_id": 4,
"allowedMimes": [
"audio",

View File

@@ -31,6 +31,7 @@ services:
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
environment:

View File

View File

@@ -0,0 +1,38 @@
-- Add visibility and slug columns to items table
ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0;
ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE;
ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0;
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug);
COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private';
COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access';
COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
-- Populate missing slugs for existing items
DO $$
DECLARE
r RECORD;
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
new_slug text;
i integer;
BEGIN
FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP
LOOP
new_slug := '';
FOR i IN 1..11 LOOP
new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1);
END LOOP;
BEGIN
UPDATE items SET slug = new_slug WHERE id = r.id;
EXIT;
EXCEPTION WHEN unique_violation THEN
END;
END LOOP;
END LOOP;
END $$;
-- Fix any items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
ALTER TABLE items ALTER COLUMN visibility SET NOT NULL;
ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0;

View File

@@ -114,6 +114,7 @@ DROP INDEX IF EXISTS public.idx_items_username;
DROP INDEX IF EXISTS public.idx_items_pinned_id;
DROP INDEX IF EXISTS public.idx_items_is_purged;
DROP INDEX IF EXISTS public.idx_items_is_deleted;
DROP INDEX IF EXISTS public.idx_items_slug;
DROP INDEX IF EXISTS public.idx_items_active_id;
DROP INDEX IF EXISTS public.idx_global_chat_created_at;
DROP INDEX IF EXISTS public.idx_discord_queue_sent;
@@ -910,12 +911,18 @@ CREATE TABLE public.items (
original_filename text,
title text,
width integer,
height integer
height integer,
visibility smallint DEFAULT 0 NOT NULL,
slug character varying(16)
);
ALTER TABLE public.items OWNER TO f0ckm;
COMMENT ON COLUMN public.items.visibility IS '0=public, 1=unlisted, 2=private';
COMMENT ON COLUMN public.items.slug IS 'Unique unguessable 11-character URL slug for direct access';
--
-- Name: COLUMN items.src; Type: COMMENT; Schema: public; Owner: f0ckm
--
@@ -1500,12 +1507,23 @@ CREATE TABLE public.user_options (
comment_display_mode integer DEFAULT 1,
force_comment_display_mode integer DEFAULT 0,
favorites_private boolean DEFAULT false,
hide_fav_badge boolean DEFAULT false
hide_fav_badge boolean DEFAULT false,
default_upload_visibility smallint DEFAULT 0,
banner_file character varying(255) DEFAULT NULL::character varying,
banner_position character varying(50) DEFAULT 'center'::character varying,
banner_size character varying(50) DEFAULT 'cover'::character varying
);
ALTER TABLE public.user_options OWNER TO f0ckm;
COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/';
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner';
--
-- Name: COLUMN user_options.avatar_file; Type: COMMENT; Schema: public; Owner: f0ckm
--
@@ -2164,6 +2182,13 @@ CREATE INDEX idx_global_chat_created_at ON public.global_chat USING btree (creat
CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true);
--
-- Name: idx_items_slug; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE UNIQUE INDEX idx_items_slug ON public.items USING btree (slug);
--
-- Name: idx_items_is_deleted; Type: INDEX; Schema: public; Owner: f0ckm
--
@@ -2946,4 +2971,9 @@ ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
-- User Banners
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG

View File

@@ -0,0 +1,2 @@
-- Fix items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;

View File

@@ -362,6 +362,7 @@
}
.upload-form.shitpost-mode-active .global-rating-section,
.upload-form.shitpost-mode-active .global-visibility-section,
.upload-form.shitpost-mode-active .global-comment-section,
.upload-form.shitpost-mode-active .global-tag-section {
display: none !important;
@@ -419,6 +420,26 @@
border-color: var(--badge-nsfl);
}
/* Visibility Container - Only checked option is colored, unchecked options are gray */
.item-visibility-container .item-rating-option input:checked + .item-rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
color: #51cf66;
border-color: rgba(81, 207, 102, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
color: #ffbb33;
border-color: rgba(255, 187, 51, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
border-color: rgba(255, 68, 68, 0.5);
}
.item-rating-label:hover {
@@ -662,6 +683,32 @@
opacity: 1;
}
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
border-color: #51cf66;
color: #51cf66;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
border-color: #ffbb33;
color: #ffbb33;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
border-color: #ff4444;
color: #ff4444;
box-shadow: none;
opacity: 1;
}
/* Tags */
.tag-input-container {
background: rgba(255, 255, 255, 0.05);

View File

@@ -1,18 +1,24 @@
(async () => {
// Helper to get dynamic context
const getContext = () => {
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const usernameEl = document.querySelector("a#a_username");
return {
postid: +idLink.innerText,
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
poster: (usernameEl?.dataset?.username || '').trim() || null,
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
};
};

View File

@@ -2405,10 +2405,50 @@ class CommentSystem {
}
}, { passive: true });
// Global click listener to close popups (useful for mobile dismissal)
// Global click listener for comment interaction (popups & expanding truncated comments in previews)
document.addEventListener('click', (e) => {
const isLink = e.target.closest('.comment-context-link');
const isPopup = e.target.closest('.comment-preview-popup');
const target = e.target;
// Load full comment (expand truncated)
const loadFullBtn = target.closest('.load-full-comment-btn');
if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.collapse-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less';
contentEl.insertAdjacentHTML('beforeend',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
}
// Collapse full comment back to truncated view
const collapseBtn = target.closest('.collapse-comment-btn');
if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.load-full-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
}
const isLink = target.closest('.comment-context-link');
const isPopup = target.closest('.comment-preview-popup');
if (!isLink && !isPopup) {
this.closePreviewsAboveLevel(-1);
@@ -2823,7 +2863,10 @@ class CommentSystem {
if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) {
const fullContent = contentEl.dataset.raw;
if (contentEl.querySelector('.collapse-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
// Append "see less" button after full content
@@ -2831,6 +2874,7 @@ class CommentSystem {
contentEl.insertAdjacentHTML('beforeend',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
@@ -2841,9 +2885,13 @@ class CommentSystem {
if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) {
const fullContent = contentEl.dataset.raw;
if (contentEl.querySelector('.load-full-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;

View File

@@ -965,7 +965,7 @@ window.cancelAnimFrame = (function () {
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal'
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal', 'visibility-modal'
];
modalIds.forEach(id => {
// Don't close the filter modal during a background mime-filter reload
@@ -2442,8 +2442,8 @@ window.cancelAnimFrame = (function () {
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
const parts = pathname.split('/').filter(Boolean);
const isItem = !pathname.match(/\/p\//) && (
pathname.match(/^\/\d+/) ||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1]))
pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
);
const isMessages = !!pathname.match(/^\/messages(\/|$)/);
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
@@ -3356,19 +3356,19 @@ window.cancelAnimFrame = (function () {
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
// Split path, filter numeric, pop last.
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s));
// Clear and Hide navbar pagination for Item View (Never show grid pagination on item view)
// document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = ''); // Don't destroy content (cached grid needs it)
document.querySelectorAll('.pagination-container-fluid').forEach(el => el.style.display = 'none');
if (numericSegments.length === 0) {
console.warn("loadItemAjax: No ID match found in URL", url);
if (keySegments.length === 0) {
console.warn("loadItemAjax: No ID or slug match found in URL", url);
// fallback for weird/external links
window.location.href = url;
return;
}
const itemid = numericSegments.pop();
const itemid = keySegments.pop();
// Extract context from Target URL first
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
@@ -3551,18 +3551,19 @@ window.cancelAnimFrame = (function () {
}
const _hash = new URL(url, window.location.origin).hash;
let _pushUrl = `/${itemid}`;
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
const itemKey = _cachedItem.slug || itemid;
let _pushUrl = `/${itemKey}`;
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; }
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemKey}`;
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`;
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
if (_hash) _pushUrl += _hash;
if (!options.keepMedia && !options.skipPush) history.pushState({}, '', _pushUrl);
document.title = `${window.f0ckDomain} - ${itemid}`;
document.title = `${window.f0ckDomain} - ${itemKey}`;
if (navbar) navbar.classList.remove('pbwork');
if (!options.keepMedia) {
@@ -3587,14 +3588,14 @@ window.cancelAnimFrame = (function () {
.then(r => r.ok ? r.text() : null)
.then(freshText => {
if (!freshText) return;
try { JSON.parse(freshText); } catch(_) {}
// Re-parse to get html string
let freshHtml = freshText;
let freshSlug = null;
try {
const d = JSON.parse(freshText);
if (d && typeof d.html === 'string') freshHtml = d.html;
if (d && (d.slug || d.item?.slug)) freshSlug = d.slug || d.item?.slug;
} catch(_) {}
itemCacheMap.set(_itemCacheKey, { html: freshHtml, ts: Date.now() });
itemCacheMap.set(_itemCacheKey, { html: freshHtml, slug: freshSlug, ts: Date.now() });
window.f0ckDebug('[itemCache] Background revalidation complete for', _itemCacheKey);
})
.catch(() => {});
@@ -3619,7 +3620,7 @@ window.cancelAnimFrame = (function () {
- Total Network: ${(tBody - tStart).toFixed(2)}ms
- Content Size: ${(rawText.length / 1024).toFixed(2)} KB`);
let html, paginationHtml;
let html, paginationHtml, responseSlug = null;
try {
// Optimistically try to parse as JSON first
@@ -3632,6 +3633,7 @@ window.cancelAnimFrame = (function () {
if (data && typeof data.html === 'string') {
html = data.html;
paginationHtml = data.pagination;
responseSlug = data.slug || data.item?.slug || null;
} else {
html = rawText;
}
@@ -3642,7 +3644,7 @@ window.cancelAnimFrame = (function () {
// ── Store in item cache (stale-while-revalidate) ───────────────────────
if (html && !options.skipCache) {
itemCacheMap.set(_itemCacheKey, { html, ts: Date.now() });
itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, ts: Date.now() });
if (itemCacheMap.size > ITEM_CACHE_MAX) {
// Evict oldest entry
itemCacheMap.delete(itemCacheMap.keys().next().value);
@@ -3749,26 +3751,25 @@ window.cancelAnimFrame = (function () {
// Construct proper History URL (Context Aware)
// If we inherited context, we should reflect it in the URL
const hash = new URL(url, window.location.origin).hash;
let pushUrl = `/${itemid}`;
const itemKey = responseSlug || itemid;
let pushUrl = `/${itemKey}`;
// Logic from ajax.mjs context reconstruction:
if (userHall && userHallOwner) {
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
} else if (user) {
pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`;
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`;
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`;
}
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`;
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemKey}`;
// Append tagger filter so item nav stays in tagger context
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
if (mime) {
// If it already has itemid at the end, insert mime before it
pushUrl = pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
// If it already has itemKey at the end, insert mime before it
pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
}
// Re-append hash if present
if (hash) pushUrl += hash;
@@ -3788,8 +3789,7 @@ window.cancelAnimFrame = (function () {
if (window.initVisualizer) window.initVisualizer();
}
// Try to extract ID from response if possible or just use itemid
document.title = `${window.f0ckDomain} - ${itemid}`;
if (navbar) navbar.classList.remove("pbwork");
document.title = `${window.f0ckDomain} - ${itemKey}`;
window.f0ckDebug("AJAX load complete");
// Notify extensions — also triggers CommentSystem init which renders comments
@@ -3821,6 +3821,7 @@ window.cancelAnimFrame = (function () {
console.error("AJAX load failed:", err);
} finally {
isNavigating = false;
if (navbar) navbar.classList.remove("pbwork");
}
};
@@ -3999,15 +4000,16 @@ window.cancelAnimFrame = (function () {
fetch(randomUrl)
.then(r => r.json())
.then(data => {
if (data.success && data.items && data.items.id) {
if (data.success && data.items && (data.items.slug || data.items.id)) {
const targetKey = data.items.slug || data.items.id;
// Navigate in the same context (user hall, favs, tag, etc.)
if (wUserHall && wUserHallOwner) {
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${data.items.id}`, true);
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true);
} else if (wFavsUser) {
// Preserve /user/:name/favs/:id context so next/prev arrows stay within favs
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${data.items.id}`, true);
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true);
} else {
loadItemAjax(`/${data.items.id}`, true);
loadItemAjax(`/${targetKey}`, true);
}
} else {
window.location.href = link.href;
@@ -4079,7 +4081,7 @@ window.cancelAnimFrame = (function () {
return false;
}
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
e.preventDefault();
e.stopImmediatePropagation();
@@ -4092,9 +4094,10 @@ window.cancelAnimFrame = (function () {
const parts = pathname.split('/').filter(Boolean);
const isItemLink = !pathname.match(/\/p\//) && (
pathname.match(/^\/\d+/) ||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) ||
(parts.length >= 3 && parts[0] === 'h' && /^\d+$/.test(parts[parts.length - 1]))
pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
);
if (isItemLink) {
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
@@ -4162,6 +4165,25 @@ window.cancelAnimFrame = (function () {
})
.catch(console.error);
} else if (target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn')) {
e.preventDefault();
const visTrigger = target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn');
const id = visTrigger.dataset.itemId || document.getElementById('visibility-item-id')?.value;
const visBtnMain = document.getElementById('a_visibility');
const rawVis = visTrigger.dataset.visibility ?? visBtnMain?.dataset.visibility ?? '0';
const currentVis = parseInt(rawVis, 10);
const modal = document.getElementById('visibility-modal');
if (modal) {
const inputId = document.getElementById('visibility-item-id');
if (inputId && id) inputId.value = id;
const radios = modal.querySelectorAll('input[name="visibility"]');
radios.forEach(r => {
r.checked = (parseInt(r.value, 10) === currentVis);
});
modal.style.display = 'flex';
document.body.classList.add('modal-open');
}
} else if (target.closest('#a_rethumb')) {
e.preventDefault();
const reBtn = target.closest('#a_rethumb');
@@ -4530,8 +4552,10 @@ window.cancelAnimFrame = (function () {
const parts = p.split('/').filter(Boolean);
const isItem = !p.match(/\/p\//) && (
p.match(/^\/\d+/) ||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1]))
p.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
);
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
@@ -8103,7 +8127,7 @@ class NotificationSystem {
}
}
if (typeof window.loadItemAjax === 'function' && href.match(/^\/\d+/)) {
if (typeof window.loadItemAjax === 'function' && (href.match(/^\/\d+/) || href.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || href.match(/\/(?:user|tag|h)\/.*?\/(?:\d+|[a-zA-Z0-9_-]{11})/))) {
window.loadItemAjax(href, false);
} else if (typeof window.loadPageAjax === 'function') {
window.loadPageAjax(href, true);
@@ -8389,7 +8413,8 @@ class NotificationSystem {
}
renderHistoryItem(n) {
let link = `/${n.item_id}`;
const itemKey = n.item_slug || n.slug || n.item_id;
let link = `/${itemKey}`;
let msg = '';
let user = n.from_display_name || n.from_user || 'System';
@@ -8398,14 +8423,14 @@ class NotificationSystem {
const isDeleted = n.type === 'item_deleted';
const label = isDeleted ? (i18n.notif_upload_deleted || 'A moderator deleted your upload') : (i18n.notif_upload_denied || 'Your Upload was denied');
const userLabel = isDeleted ? (i18n.notif_moderation || 'Moderation') : (i18n.notif_system || 'System');
const itemLink = `/${n.item_id}`;
const itemLink = `/${itemKey}`;
return `
<a href="${itemLink}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
<div class="notif-content">
<div class="notif-user"><strong>${userLabel}</strong></div>
<div class="notif-msg">
<strong>${label} #${n.item_id}</strong>
<strong>${label} #${itemKey}</strong>
<div class="notif-reason">${i18n.notif_reason_label || 'Reason:'} ${n.reason || (i18n.notif_no_reason || 'No reason provided')}</div>
</div>
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
@@ -8425,7 +8450,7 @@ class NotificationSystem {
if (n.data?.msg) msg += ` <br><span style="color: #ff6060; font-size: 0.9em;">${n.data.msg}</span>`;
if (n.data?.url) msg += ` <br><small style="opacity: 0.6; word-break: break-all;">${n.data.url}</small>`;
user = (window.f0ckI18n && window.f0ckI18n.notif_system) || 'System';
link = n.item_id ? `/${n.item_id}` : '#';
link = n.item_id ? `/${itemKey}` : '#';
} else if (n.type === 'admin_pending') {
link = '/mod/approve';
user = (window.f0ckI18n && window.f0ckI18n.notif_admin) || 'Admin';
@@ -8441,7 +8466,7 @@ class NotificationSystem {
if (n.reason) msg += `<br><div class="notif-reason" style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${n.reason}</div>`;
} else {
// Comment notification
link = `/${n.item_id}#c${n.comment_id || n.reference_id}`;
link = `/${itemKey}#c${n.comment_id || n.reference_id}`;
if (n.type === 'comment_reply') msg = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
else if (n.type === 'subscription') msg = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
else if (n.type === 'mention') msg = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
@@ -8482,8 +8507,9 @@ class NotificationSystem {
}
renderItem(n) {
const itemKey = n.item_slug || n.slug || n.item_id;
if (n.type === 'approve') {
const link = `/${n.item_id}`;
const link = `/${itemKey}`;
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/${n.item_id}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';};}"></div>
@@ -8498,7 +8524,7 @@ class NotificationSystem {
}
if (n.type === 'upload_success') {
const link = `/${n.item_id}`;
const link = `/${itemKey}`;
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none';"></div>
@@ -8517,7 +8543,7 @@ class NotificationSystem {
const errMsg = n.data?.msg || '';
const errDisplay = errMsg ? `<div style="color: #ffbaba; font-size: 0.85em; margin-top: 2px;">${errMsg}</div>` : '';
const urlDisplay = url ? `<div style="font-size: 0.8em; opacity: 0.7; margin-top: 4px; word-break: break-all; max-height: 3.2em; overflow: hidden;">${url}</div>` : '';
const link = n.item_id ? `/${n.item_id}` : '#';
const link = n.item_id ? `/${itemKey}` : '#';
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
<div class="notif-content">
@@ -8541,7 +8567,7 @@ class NotificationSystem {
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/t/${n.item_id}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/${n.item_id}.webp';this.onerror=function(){this.style.display='none';}"></div>
<div class="notif-content">
<div>
<strong>${label} #${n.item_id}</strong>
<strong>${label} #${itemKey}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${(window.f0ckI18n && window.f0ckI18n.notif_click_reason) || 'Click to see reason'}</div>
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
@@ -8600,10 +8626,10 @@ class NotificationSystem {
if (n.type === 'comment_reply') typeText = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
else if (n.type === 'subscription') typeText = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
else if (n.type === 'mention') typeText = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${n.item_id}`;
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${itemKey}`;
const cid = n.comment_id || n.reference_id;
const link = `/${n.item_id}#c${cid}`;
const link = `/${itemKey}#c${cid}`;
const thumb = n.item_id ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none'"></div>` : '';
return `
@@ -8768,10 +8794,11 @@ class NotificationSystem {
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
const fragment = document.createDocumentFragment();
data.favs.forEach(fav => {
if (fav.hide_fav_badge) {
const isSelf = window.f0ckSession && window.f0ckSession.user && (window.f0ckSession.user === fav.user);
if (fav.hide_fav_badge && !isSelf) {
const a = document.createElement('a');
a.className = 'ghost-fav';
a.setAttribute('tooltip', 'Ghost Fav');
a.setAttribute('tooltip', '?');
a.setAttribute('flow', 'up');
a.style.cursor = 'default';
@@ -8884,12 +8911,14 @@ class NotificationSystem {
if (!isOwnUpload) return; // Silently drop as before for other users' uploads
const itemKey = data.slug || data.id;
// Don't add duplicates
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
// Determine the link prefix from existing items
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
// Build filter-reason label
const i18n = window.f0ckI18n || {};
@@ -8904,7 +8933,7 @@ class NotificationSystem {
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
const ghost = document.createElement('a');
ghost.href = `${linkBase}${data.id}`;
ghost.href = `${linkBase}${itemKey}`;
ghost.className = 'thumb lazy-thumb filtered-upload-ghost loaded';
ghost.dataset.file = data.dest;
ghost.dataset.mime = data.mime;
@@ -8935,19 +8964,21 @@ class NotificationSystem {
}
}
const itemKey = data.slug || data.id;
// Don't add duplicates
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
// Determine the link prefix from existing items
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
// Respect mode filter
const nsflId = window.f0ckSession?.nsfl_tag_id;
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
const thumb = document.createElement('a');
thumb.href = `${linkBase}${data.id}`;
thumb.href = `${linkBase}${itemKey}`;
thumb.className = 'thumb lazy-thumb';
thumb.dataset.file = data.dest;
thumb.dataset.mime = data.mime;
@@ -9129,10 +9160,11 @@ class NotificationSystem {
</div>`;
}
const itemKey = c.item_slug || c.slug || c.item_id;
itemPreview = `
<div class="item-preview" style="margin-top: 10px; display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.2); padding: 5px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05);">
<a href="/${c.item_id}">${mediaHtml}</a>
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context &raquo;</a>
<a href="/${itemKey}">${mediaHtml}</a>
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context &raquo;</a>
</div>`;
}
@@ -11302,6 +11334,83 @@ document.addEventListener('click', (e) => {
return;
}
const visCancelBtn = e.target.closest('#visibility-modal-cancel');
if (visCancelBtn || e.target.id === 'visibility-modal') {
const modal = document.getElementById('visibility-modal');
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
return;
}
const visSaveBtn = e.target.closest('#visibility-modal-save');
if (visSaveBtn) {
e.preventDefault();
const modal = document.getElementById('visibility-modal');
const inputId = document.getElementById('visibility-item-id');
const selectedRadio = modal ? modal.querySelector('input[name="visibility"]:checked') : null;
if (!inputId || !selectedRadio) return;
const id = inputId.value;
const nextVis = parseInt(selectedRadio.value, 10);
visSaveBtn.disabled = true;
const origText = visSaveBtn.textContent;
visSaveBtn.textContent = 'Saving...';
fetch('/api/v2/item/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams({ postid: id, id: id, visibility: nextVis })
})
.then(r => r.json())
.then(data => {
visSaveBtn.disabled = false;
visSaveBtn.textContent = origText;
if (data.success) {
const visVal = parseInt(data.visibility, 10);
const titles = ['Public', 'Unlisted', 'Private'];
const icons = ['fa-globe', 'fa-link', 'fa-lock'];
const colors = ['var(--color-success, #00C851)', 'var(--color-warning, #ffbb33)', 'var(--color-danger, #ff4444)'];
const visBtn = document.getElementById('a_visibility');
if (visBtn) {
visBtn.dataset.visibility = visVal;
visBtn.setAttribute('title', `Visibility: ${titles[visVal]} (Click to change)`);
}
const infoVisBtn = document.getElementById('info-visibility-edit-btn');
if (infoVisBtn) {
infoVisBtn.dataset.visibility = visVal;
}
const infoVisLabel = document.getElementById('info-visibility-label');
if (infoVisLabel) {
infoVisLabel.innerHTML = `<i class="fa-solid ${icons[visVal]}" style="color: ${colors[visVal]};"></i> ${titles[visVal]}`;
}
if (modal) {
modal.style.display = 'none';
document.body.classList.remove('modal-open');
}
if (window.flashMessage) window.flashMessage(`VISIBILITY SET TO ${titles[visVal].toUpperCase()}`);
} else {
if (window.flashError) window.flashError(data.msg || 'Failed to update visibility');
}
})
.catch(err => {
visSaveBtn.disabled = false;
visSaveBtn.textContent = origText;
console.error('Error changing visibility:', err);
if (window.flashError) window.flashError('Network error');
});
return;
}
// Title save button
const saveBtn = e.target.closest('#info-title-save');
if (saveBtn) {

View File

@@ -1181,6 +1181,35 @@
});
}
const defaultUploadVisSelect = document.getElementById('default_upload_visibility_select');
if (defaultUploadVisSelect) {
defaultUploadVisSelect.addEventListener('change', async function() {
const vis = parseInt(defaultUploadVisSelect.value, 10);
try {
const res = await fetch('/api/v2/settings/default_upload_visibility', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ default_upload_visibility: vis })
});
const data = await res.json();
if (data.success) {
showStatus('Default upload visibility updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.default_upload_visibility = vis;
}
} else {
alert(data.msg || 'Error saving preference');
}
} catch (err) {
console.error('Update Default Upload Visibility error:', err);
alert('Connection error');
}
});
}
// New Dual Column Layout Toggle
const layoutToggle = document.getElementById('use_new_layout_toggle');
if (layoutToggle) {

View File

@@ -479,8 +479,9 @@
};
const renderActivityItem = (c) => {
const itemKey = c.item_slug || c.slug || c.item_id;
const rawContent = c.content || c.body || '';
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
let displayContent = renderCommentContent(rawContent, c.id, itemKey);
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
@@ -497,7 +498,7 @@
}
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
// Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png';
@@ -542,8 +543,8 @@
itemPreview = `
<div class="item-preview">
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
<a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
</div>`;
}

View File

@@ -908,7 +908,7 @@ window.initUploadForm = (selector) => {
}
lines.forEach(url => {
if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) {
selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false });
selectedFiles.push({ type: 'url', url, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
}
});
urlInput.value = '';
@@ -931,7 +931,7 @@ window.initUploadForm = (selector) => {
const val = urlInput.value.trim();
if (!val || !/^https?:\/\//i.test(val)) return;
if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) {
selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false });
selectedFiles.push({ type: 'url', url: val, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
}
urlInput.value = '';
if (urlBadge) urlBadge.style.display = 'none';
@@ -1177,7 +1177,7 @@ window.initUploadForm = (selector) => {
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
if (isShitpost) {
selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false });
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
} else {
selectedFiles.push(file); // Legacy single file mode uses raw File
}
@@ -1490,6 +1490,7 @@ window.initUploadForm = (selector) => {
infoRow.className = 'file-meta-row-small';
let ratingSwitch = '';
let visibilitySwitch = '';
let tagsUI = '';
let ocUI = '';
let commentUI = '';
@@ -1517,6 +1518,31 @@ window.initUploadForm = (selector) => {
</div>
`;
const hasVisSection = !!form.querySelector('.global-visibility-section');
if (hasVisSection) {
const globalVis = form.querySelector('input[name="visibility"]:checked')?.value || '0';
const visValue = (item.visibility !== undefined && item.visibility !== '') ? item.visibility : globalVis;
item.visibility = visValue;
visibilitySwitch = `
<div class="item-visibility-container item-rating-container" style="margin-top: 4px;">
<label class="item-rating-option">
<input type="radio" name="visibility_${index}" value="0" ${visValue === '0' || visValue === 0 ? 'checked' : ''}>
<span class="item-rating-label sfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-globe"></i> Public</span>
</label>
<label class="item-rating-option">
<input type="radio" name="visibility_${index}" value="1" ${visValue === '1' || visValue === 1 ? 'checked' : ''}>
<span class="item-rating-label nsfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-link"></i> Unlisted</span>
</label>
<label class="item-rating-option">
<input type="radio" name="visibility_${index}" value="2" ${visValue === '2' || visValue === 2 ? 'checked' : ''}>
<span class="item-rating-label nsfl" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-lock"></i> Private</span>
</label>
</div>
`;
} else {
visibilitySwitch = '';
}
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
tagsUI = `
@@ -1560,19 +1586,27 @@ window.initUploadForm = (selector) => {
</div>
${titleUI}
${ratingSwitch}
${visibilitySwitch}
${tagsUI}
${commentUI}
`;
if (isShitpost) {
// Handle Rating
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
radio.onchange = () => {
item.rating = radio.value;
updateSubmitButton();
};
});
// Handle Visibility
infoRow.querySelectorAll('.item-visibility-container input').forEach(radio => {
radio.onchange = () => {
item.visibility = radio.value;
};
});
// Handle Comment
const commentInput = infoRow.querySelector('.item-comment-input');
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
@@ -2436,6 +2470,8 @@ window.initUploadForm = (selector) => {
}
try {
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
const visibilityVal = globalVisEl ? globalVisEl.value : '0';
const resp = await fetch('/api/v2/upload-url', {
method: 'POST',
headers: {
@@ -2445,7 +2481,8 @@ window.initUploadForm = (selector) => {
},
body: JSON.stringify({
url,
rating: globalRatingEl.value,
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
visibility: visibilityVal,
tags: tags.join(','),
comment: comment,
is_oc: isOc,
@@ -2552,9 +2589,11 @@ window.initUploadForm = (selector) => {
for (let i = 0; i < selectedFiles.length; i++) {
const item = selectedFiles[i];
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
const isUrlItem = isShitpost && item.type === 'url';
const file = !isUrlItem ? (isShitpost ? item.file : item) : null;
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
const fileVisibility = isShitpost ? (item.visibility || globalVisEl?.value || '0') : (globalVisEl?.value || '0');
const fileTags = isShitpost ? item.tags : tags;
const fileComment = isShitpost ? item.comment : comment;
const fileTitle = isShitpost ? (item.title || '') : titleVal;
@@ -2571,6 +2610,7 @@ window.initUploadForm = (selector) => {
formData.append('file', file);
}
formData.append('rating', fileRating);
formData.append('visibility', fileVisibility);
formData.append('tags', fileTags.join(','));
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
if (isShitpost) formData.append('is_shitpost', 'true');
@@ -2622,6 +2662,7 @@ window.initUploadForm = (selector) => {
xhr.send(JSON.stringify({
url: item.url,
rating: fileRating,
visibility: fileVisibility,
tags: fileTags.join(','),
is_oc: (isShitpost ? item.is_oc : isOc),
comment: fileComment,
@@ -2738,17 +2779,14 @@ window.initUploadForm = (selector) => {
// Skip redirect if every item was a background URL job
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
if (!allPending) {
// Inject now if the grid is already in the DOM (upload modal open on main page)
injectNewItem();
// Navigate to main page, then inject again after the grid has loaded.
// Awaiting loadPageAjax ensures the .posts grid DOM is present before the
// handleNewItem call — this covers the item-page drag-and-upload scenario
// where no grid exists until after navigation completes.
const targetUrl = (lastData && (lastData.visibility > 0 || lastData.redirect || lastData.slug))
? (lastData.redirect || `/${lastData.slug || lastData.itemid}`)
: '/';
if (typeof window.loadPageAjax === 'function') {
await window.loadPageAjax('/', true, { bypassCache: true });
injectNewItem();
await window.loadPageAjax(targetUrl, true, { bypassCache: true });
if (targetUrl === '/') injectNewItem();
} else {
window.location.href = '/';
window.location.href = targetUrl;
}
}
} else {

View File

@@ -1,14 +1,20 @@
(async () => {
// Helper to get dynamic context from the DOM
const getContext = () => {
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
return {
postid: +idLink.innerText,
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
poster: document.querySelector("a#a_username")?.innerText,
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
};
};

View File

@@ -410,11 +410,12 @@ if (!window.UserCommentSystem) {
}
renderComment(c) {
const itemKey = c.item_slug || c.slug || c.item_id;
const timeAgo = this.timeAgo(c.created_at);
const fullDate = new Date(c.created_at).toISOString();
const content = this.renderCommentContent(c.content, c.item_id);
const content = this.renderCommentContent(c.content, itemKey);
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
}
startLiveTimestamps() {

View File

@@ -38,6 +38,16 @@ export default new class {
.replace(/'/g, "&#039;");
}
generateSlug(length = 11) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
let slug = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
slug += chars[bytes[i] % chars.length];
}
return slug;
}
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
};

View File

@@ -169,6 +169,8 @@
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
"default_upload_visibility": "Standard Upload-Sichtbarkeit",
"default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.",
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -799,6 +801,12 @@
"delete_confirm": "Diesen Einladungstoken löschen?",
"slot_refreshes_on": "Slot erneuert sich am {date}",
"slot_refreshed": "Slot erneuert",
"admin_desc": "Du bist Admin, leg los."
"admin_desc": "Du bist Admin, leg los.",
"visibility": {
"public": "Öffentlich",
"unlisted": "Nicht gelistet",
"private": "Privat",
"change_visibility": "Sichtbarkeit ändern"
}
}
}

View File

@@ -169,6 +169,8 @@
"favorites_private_hint": "Only you and administrators can view your favorites list.",
"hide_fav_badge": "Hide Favorite Badge Avatar",
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
"default_upload_visibility": "Default Upload Visibility",
"default_upload_visibility_hint": "Set the default visibility level for your new uploads.",
"image_expand_on_click": "Expand images inline on click",
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
"enable_bg_blur": "Enable Background blur",
@@ -801,6 +803,12 @@
"delete_confirm": "Delete this invite token?",
"slot_refreshes_on": "slot refreshes on {date}",
"slot_refreshed": "slot refreshed",
"admin_desc": "You are an admin, go ahead."
"admin_desc": "You are an admin, go ahead.",
"visibility": {
"public": "Public",
"unlisted": "Unlisted",
"private": "Private",
"change_visibility": "Change Visibility"
}
}
}

View File

@@ -302,6 +302,9 @@ export default {
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
}
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce(items.visibility, 0) = 0`;
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
let total = getCachedCount(cacheKey);
@@ -313,6 +316,7 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -355,6 +359,7 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -385,6 +390,8 @@ export default {
const rows = (await db`
select
items.id,
items.slug,
items.visibility,
items.mime,
items.dest,
items.username as username,
@@ -521,7 +528,17 @@ export default {
if (uhData.length) userHallObj = uhData[0];
}
const mime = (rawMime ?? "");
const itemid = rawItemid ? +rawItemid : null;
const rawIdOrSlug = rawItemid ?? null;
if (rawIdOrSlug === null || rawIdOrSlug === undefined || rawIdOrSlug === '') {
return {
success: false,
message: "404 - upload not found"
};
}
const isNumeric = /^\d+$/.test(String(rawIdOrSlug));
const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`;
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash'
@@ -535,19 +552,12 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
const effMode = Number(mode ?? 0);
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
const modequery = multiRatingSQL ?? lib.getMode(effMode);
if (itemid === null) {
return {
success: false,
message: "404 - upload not found"
};
}
let tagFilter = db``;
let titleFilter = db``;
if (isTitleSearch && titleQuery) {
@@ -588,6 +598,7 @@ export default {
return db`
${db.unsafe(modequery)}
and items.active = true
and coalesce(items.visibility, 0) = 0
${tagFilter}
${titleFilter}
${hallFilter}
@@ -601,11 +612,9 @@ export default {
};
const startTime = Date.now();
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
// 1. Fetch the main item
// We only apply the active check and global NSFW filter (for guests) here.
// We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible).
const items = await db`
select distinct on (items.id)
items.*,
@@ -629,7 +638,7 @@ export default {
left join "user_options" uo on uo.user_id = author_u.id
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
where
items.id = ${itemid} and
${itemLookup} and
items.active = true
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
limit 1
@@ -637,7 +646,41 @@ export default {
const actitem = items[0];
if (actitem && user_id) {
if (!actitem) {
return {
success: false,
message: "404 - upload not found"
};
}
const itemid = actitem.id;
// Check visibility permissions:
const isOwnerOrAdmin = session && (
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
session.admin || session.is_moderator
);
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
// Block numeric enumeration unless viewer is owner/admin
if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
return {
success: false,
message: "404 - upload not found"
};
}
// If item is Private (visibility === 2):
// Direct link only allowed for owner/admin
if (actitem.visibility === 2 && !isOwnerOrAdmin) {
return {
success: false,
is_private: true,
message: "403 - private upload"
};
}
if (user_id) {
db`
insert into user_video_views (user_id, video_id, view_count, last_viewed)
values (${user_id}, ${itemid}, 1, now())
@@ -646,34 +689,26 @@ export default {
last_viewed = now()
`.catch(e => console.error('Failed to track view:', e));
}
if (!actitem) {
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
if (!session && getGlobalfilter()) {
// Guest global filter check if item was filtered out
if (!session && getGlobalfilter() && !actitem) {
const unfilteredItem = await db`
select id from items where id = ${itemid} and active = true limit 1
select id from items where ${itemLookup} and active = true limit 1
`;
if (unfilteredItem[0]) {
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
message: "Sorry, this post is currently not visible.",
item: {
id: itemid,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}_blur.webp`,
id: unfilteredItem[0].id,
og_thumbnail: `${cfg.websrv.paths.thumbnails}/${unfilteredItem[0].id}_blur.webp`,
og_url: hallSlug
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
: `https://${cfg.main.url.domain}/${itemid}`,
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${unfilteredItem[0].id}`
: `https://${cfg.main.url.domain}/${unfilteredItem[0].id}`,
og_description: `Content not visible in current mode`
}
};
}
}
return {
success: false,
message: "Sorry, this post is currently not visible."
};
}
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
@@ -685,7 +720,7 @@ export default {
const baseQuery = (whereClause, orderBy, limit = 1) => {
return db`
select items.id
select items.id, items.slug
from items
left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id
@@ -696,7 +731,7 @@ export default {
where
${buildConditions()}
${whereClause}
group by items.id
group by items.id, items.slug
${orderBy}
limit ${limit}
`;
@@ -711,7 +746,7 @@ export default {
const checkFilter = !session && nsfpIds.length > 0;
const query = db`
SELECT ta.item_id as id
SELECT ta.item_id as id, items.slug
FROM tags_assign ta
INNER JOIN items ON items.id = ta.item_id
${checkFilter
@@ -720,6 +755,7 @@ export default {
}
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
AND items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -781,24 +817,24 @@ export default {
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
const baseChecksum = actitem.checksum.split('_bypass_')[0];
const repostRows = await db`
SELECT id, username, stamp FROM items
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
} else if (actitem.checksum) {
// Even without bypass, check if other bypass-entries exist with this same hash
const baseChecksum = actitem.checksum;
const repostRows = await db`
SELECT id, username, stamp FROM items
SELECT id, slug, username, stamp FROM items
WHERE active = true
AND id != ${itemid}
AND checksum LIKE ${baseChecksum + '_bypass_%'}
ORDER BY id ASC
`;
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
}
// Also find visually-similar items via phash, merging with checksum results
@@ -808,7 +844,7 @@ export default {
const existingIds = new Set(repostItems.map(r => r.id));
for (const pm of phashMatches) {
if (!existingIds.has(pm.id)) {
repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
existingIds.add(pm.id);
}
}
@@ -843,7 +879,7 @@ export default {
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
if (modeBlocked) {
if (modeBlocked && !isOwnerOrAdmin && actitem.visibility !== 1) {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return {
success: false,
@@ -871,6 +907,8 @@ export default {
},
item: {
id: actitem.id,
slug: actitem.slug || null,
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
username: actitem.username,
author_id: actitem.author_id,
author_color: actitem.author_color,
@@ -933,13 +971,13 @@ export default {
height: actitem.height || null,
original_filename: actitem.original_filename || null
},
title: `${actitem.id} - ${cfg.websrv.domain}`,
title: `${(cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
pagination: {
end: endItem[0]?.id || itemid,
start: startItem[0]?.id || itemid,
next: nextItem[0]?.id || null,
prev: prevItem[0]?.id || null,
page: actitem.id,
end: (cfg.enable_item_slugs !== false && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
start: (cfg.enable_item_slugs !== false && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
next: (cfg.enable_item_slugs !== false && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
prev: (cfg.enable_item_slugs !== false && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
page: (cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id,
cheat: cheat
},
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
@@ -1008,6 +1046,7 @@ export default {
WHERE
${db.unsafe(modequery)}
AND items.active = true
AND coalesce(items.visibility, 0) = 0
AND items.title ILIKE ${'%' + titleQuery + '%'}
AND items.title IS NOT NULL
${mimeSQL}
@@ -1030,6 +1069,7 @@ export default {
${db.unsafe(modequery)}
and "user".user ilike ${user}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${!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
@@ -1069,6 +1109,7 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
and coalesce(items.visibility, 0) = 0
${tagFilter}
${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``}
@@ -1091,6 +1132,7 @@ export default {
${db.unsafe(modequery)}
and h.slug = ${hall}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${!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``}
@@ -1107,6 +1149,7 @@ export default {
${db.unsafe(modequery)}
and uha.hall_id = ${userHallId}
and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL}
${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()
@@ -1133,6 +1176,7 @@ export default {
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
WHERE items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : 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``}

View File

@@ -4,7 +4,7 @@ import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs";
export default (router, tpl) => {
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
router.get(/\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
const tAjaxStart = Date.now();
let query = {};
if (typeof req.url === 'string') {
@@ -35,12 +35,12 @@ export default (router, tpl) => {
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
const data = await f0cklib.getf0ck({
itemid: itemid,
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: !!req.session,
session: req.session,
url: contextUrl,
user: query.user,
tag: query.tag,
@@ -170,7 +170,8 @@ export default (router, tpl) => {
html: itemHtml,
pagination: paginationHtml,
title: data.title,
id: itemid
id: itemid,
slug: data.item?.slug || null
})
});
});

View File

@@ -594,10 +594,12 @@ export default router => {
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
exclude: req.session?.excluded_tags || []
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
});
if (!data.itemid) {
if (!data || !data.itemid) {
return res.json({
success: false,
items: []
@@ -636,6 +638,7 @@ export default router => {
items: {
...safeItem,
id: item.id,
slug: item.slug || null,
dest: relativeDest,
url: directUrl,
direct_url: directUrl
@@ -1038,7 +1041,24 @@ export default router => {
});
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
const postid = +req.post.postid;
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
if (rawPostid === undefined || rawPostid === null) {
return res.json({ success: false, msg: 'Missing postid' }, 400);
}
// Support both numeric item ID and string slug
const isNumeric = /^\d+$/.test(String(rawPostid));
const itemRow = await db`
SELECT id FROM items
WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!itemRow.length) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const postid = itemRow[0].id;
// Check if already faved by this user — compare as numbers to avoid type mismatch
const existing = await db`
@@ -1168,6 +1188,47 @@ export default router => {
});
});
group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => {
if (cfg.enable_private_uploads === false) {
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
}
const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id;
const visibility = parseInt(req.post?.visibility ?? req.body?.visibility, 10);
if (!postid || isNaN(visibility) || ![0, 1, 2].includes(visibility)) {
return res.json({ success: false, msg: 'Invalid parameters' }, 400);
}
const isNumeric = /^\d+$/.test(String(postid));
const item = await db`
SELECT id, slug, username, visibility
FROM items
WHERE ${isNumeric ? db`id = ${+postid}` : db`slug = ${String(postid)}`} AND active = true AND is_deleted = false
LIMIT 1
`;
if (item.length === 0) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const isOwner = item[0].username === req.session.user;
const isAdmin = req.session.admin || req.session.is_moderator;
if (!isOwner && !isAdmin) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
await db`UPDATE items SET visibility = ${visibility} WHERE id = ${item[0].id}`;
f0cklib.clearCountCache();
return res.json({
success: true,
itemid: item[0].id,
slug: item[0].slug,
visibility: visibility
});
});
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);

View File

@@ -353,6 +353,30 @@ export default router => {
}
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
const vis = parseInt(req.post.default_upload_visibility, 10);
if (isNaN(vis) || ![0, 1, 2].includes(vis)) {
return res.json({ success: false, msg: 'Invalid visibility option' }, 400);
}
try {
await db`
update user_options
set default_upload_visibility = ${vis}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.default_upload_visibility = vis;
return res.json({ success: true, default_upload_visibility: vis }, 200);
} catch (e) {
console.error('Update Default Upload Visibility pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
const { color } = req.post;

View File

@@ -137,6 +137,29 @@ const parseMultipart = (buffer, boundary) => {
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
const getTargetVisibility = (req, postVis) => {
if (cfg.enable_private_uploads === false) return 0;
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
? cfg.default_upload_visibility
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
if (!allowUserOverride) {
return sysDefault;
}
const rawHeader = req.headers ? req.headers['x-upload-visibility'] : null;
const val = (rawHeader || postVis || '').toString().trim().toLowerCase();
if (val === 'private' || val === '2') return 2;
if (val === 'unlisted' || val === '1') return 1;
if (val === 'public' || val === '0') return 0;
return (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
? req.session.default_upload_visibility
: sysDefault;
};
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
@@ -369,6 +392,9 @@ export default router => {
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
const filename = `yt:${videoId}`;
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
const [{ id: itemid }] = await db`
insert into items ${db({
src: ytUrl,
@@ -383,8 +409,10 @@ export default router => {
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
title: title,
visibility: targetVisibility,
slug: itemSlug
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')}
RETURNING id
`;
@@ -437,6 +465,9 @@ export default router => {
});
} else {
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
const session = {
id: req.session.id,
user: req.session.user,
@@ -689,8 +720,10 @@ export default router => {
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
title: title,
visibility: targetVisibility,
slug: itemSlug
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')}
RETURNING id
`;
@@ -725,8 +758,8 @@ export default router => {
}
}
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!isApprovalRequired) {
// Broadcast new_item event for live grid updates (only if auto-approved and public)
if (!isApprovalRequired && targetVisibility === 0) {
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
@@ -742,8 +775,8 @@ export default router => {
}
}
// Push to Matrix Channel (only if auto-approved)
if (!isApprovalRequired) {
// Push to Matrix Channel (only if auto-approved and public)
if (!isApprovalRequired && targetVisibility === 0) {
try {
const self = router.self;
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');

View File

@@ -174,7 +174,7 @@ export default (router, tpl) => {
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
const comments = await db`
SELECT c.*, i.mime, i.id as item_id
SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug
FROM comments c
LEFT JOIN items i ON c.item_id = i.id
WHERE c.user_id = ${userId} AND c.is_deleted = false
@@ -570,6 +570,7 @@ export default (router, tpl) => {
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
const itemQuery = await db`
SELECT
i.slug,
i.xd_score,
(SELECT ta.tag_id FROM tags_assign ta
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
@@ -592,6 +593,7 @@ export default (router, tpl) => {
type: 'comment',
id: commentId,
item_id: item_id,
item_slug: itemQuery[0]?.slug || null,
parent_id: parent_id || null,
body: notifyBody,
username: req.session.user,
@@ -1003,6 +1005,7 @@ export default (router, tpl) => {
c.*,
i.mime,
i.id as item_id,
i.slug as item_slug,
i.dest as item_dest,
(SELECT ta.tag_id FROM tags_assign ta
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])

View File

@@ -73,7 +73,7 @@ export default (router, tpl) => {
ratings: ratingsArr,
mime: mime,
fav: false,
session: !!req.session,
session: req.session,
user_id: req.session?.id,
random: isRandom
});
@@ -253,7 +253,7 @@ export default (router, tpl) => {
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) {
if (data.is_private) {
if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({
code: 403,
@@ -414,11 +414,11 @@ export default (router, tpl) => {
// Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>[a-zA-Z0-9_-]+)$/, handleGenericRoute);
// Generic router for everything else (Index, Tags, standard User Grids)
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
/* </routing-refactor> */
router.get(/^\/(about)$/, (req, res) => {

View File

@@ -114,7 +114,7 @@ export default (router, tpl) => {
}
const f0ck = await db`
select i.dest, i.mime, i.username, i.id, ta.tag_id
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
from "items" i
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
where i.id = ${id} and i.active = false
@@ -156,65 +156,69 @@ export default (router, tpl) => {
console.error('[MOD APPROVE] Failed to notify user:', err);
}
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
const isPublic = (f0ck[0].visibility || 0) === 0;
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
if (isPublic) {
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
}
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
}
}
}

View File

@@ -122,7 +122,7 @@ db.listen('activity', async (payload) => {
// We need the username, avatar, and item mime for the preview
// trigger only gave us user_id and item_id
const [details] = await db`
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime,
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime, i.slug as item_slug,
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
FROM "user" u
LEFT JOIN user_options uo ON u.id = uo.user_id
@@ -138,6 +138,7 @@ db.listen('activity', async (payload) => {
data.username_color = details.username_color;
data.display_name = details.display_name || null;
data.tag_id = details.tag_id;
data.item_slug = details.item_slug;
} else {
data.username = 'System';
}
@@ -210,6 +211,7 @@ db.listen('motd', (payload) => {
db.listen('new_item', (payload) => {
try {
const data = JSON.parse(payload);
if (data.visibility && data.visibility !== 0) return;
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'new_item', data });
@@ -367,7 +369,7 @@ export default (router, tpl) => {
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
const notifications = typeFilter
? await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
@@ -392,7 +394,7 @@ export default (router, tpl) => {
OFFSET ${offset}
`
: await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
@@ -443,7 +445,7 @@ export default (router, tpl) => {
try {
const notifications = await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,

View File

@@ -97,11 +97,11 @@ export default (router, tpl) => {
`;
const favotop = await db`
select favorites.item_id, count(*) favs
select favorites.item_id as id, items.slug, count(*) favs
from favorites
join items on items.id = favorites.item_id
where items.active = true
group by favorites.item_id
group by favorites.item_id, items.slug
having count(*) > 1
order by favs desc
limit 10
@@ -110,7 +110,7 @@ export default (router, tpl) => {
let xdtop = [];
if (config.websrv.enable_xd_score) {
const xdRows = await db`
select id, xd_score
select id, slug, xd_score
from items
where active = true and is_deleted = false and xd_score > 0
order by xd_score desc

View File

@@ -268,6 +268,7 @@ export default (router, tpl) => {
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}
@@ -292,6 +293,7 @@ export default (router, tpl) => {
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}

View File

@@ -34,7 +34,7 @@ export default (router, tpl) => {
const subs = await db`
SELECT
s.created_at as sub_date,
i.id, i.dest, i.mime, i.username as uploader_name
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
@@ -45,6 +45,7 @@ export default (router, tpl) => {
const items = subs.map(i => ({
id: i.id,
slug: i.slug || null,
user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp`
@@ -108,7 +109,7 @@ export default (router, tpl) => {
const subs = await db`
SELECT
s.created_at as sub_date,
i.id, i.dest, i.mime, i.username as uploader_name
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
@@ -118,6 +119,7 @@ export default (router, tpl) => {
const items = subs.map(i => ({
id: i.id,
slug: i.slug || null,
user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp`

View File

@@ -31,7 +31,9 @@ export async function regenerateTagImage(tag, mode) {
JOIN tags_assign ta ON ta.item_id = i.id
JOIN tags t ON t.id = ta.tag_id
${modeFilter}
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true
WHERE (t.tag = ${tag} OR t.normalized = ${tag})
AND i.active = true
AND COALESCE(i.visibility, 0) = 0
ORDER BY RANDOM()
LIMIT 3
`;

View File

@@ -35,6 +35,7 @@ export default (router, tpl) => {
JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id
WHERE items.active = true
AND COALESCE(items.visibility, 0) = 0
AND t.id NOT IN (1, 2)
AND ${db.unsafe(modequery)}
${restrictedFilter}
@@ -58,6 +59,7 @@ export default (router, tpl) => {
JOIN items ON items.id = ta.item_id
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
AND ${db.unsafe(modequery)}
${restrictedFilter}
${userExcludeFilter}

View File

@@ -92,7 +92,7 @@ export default (router, tpl) => {
const data = await f0cklib.getf0cks({
page: req.params.page,
mode: req.mode,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
userHall: slug,
@@ -138,7 +138,7 @@ export default (router, tpl) => {
const data = await f0cklib.getf0ck({
itemid: req.params.itemid,
mode: req.mode,
session: !!req.session,
session: req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
userHall: slug,
@@ -260,7 +260,7 @@ export default (router, tpl) => {
FROM items i
JOIN user_halls_assign uha ON uha.item_id = i.id
${modeFilter}
WHERE uha.hall_id = ${hall.id} AND i.active = true
WHERE uha.hall_id = ${hall.id} AND i.active = true AND COALESCE(i.visibility, 0) = 0
ORDER BY RANDOM()
LIMIT 3
`;

View File

@@ -35,6 +35,7 @@ export default (router, tpl) => {
JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id
WHERE items.active = true
AND COALESCE(items.visibility, 0) = 0
AND t.id NOT IN (1, 2)
AND ta.user_id = ${userId}
AND ${db.unsafe(modequery)}
@@ -59,6 +60,7 @@ export default (router, tpl) => {
JOIN items ON items.id = ta.item_id
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
AND ta.user_id = ${userId}
AND ${db.unsafe(modequery)}
${restrictedFilter}

View File

@@ -34,7 +34,7 @@ export default async bot => {
rows = await db`
select id, mime, username, size
from "items"
where id >= ${randomId} and active = true
where id >= ${randomId} and active = true and coalesce(visibility, 0) = 0
order by id asc
limit 1
`;
@@ -43,7 +43,7 @@ export default async bot => {
rows = await db`
select id, mime, username, size
from "items"
where active = true
where active = true and coalesce(visibility, 0) = 0
order by id asc
limit 1
`;
@@ -54,7 +54,7 @@ export default async bot => {
rows = await db`
select id, mime, username, size
from "items"
where
where active = true and coalesce(visibility, 0) = 0 and
${args.map(a => a.charAt(0) === "!"
? db`username not ilike ${a.slice(1)}`
: db`username ilike ${a}`

View File

@@ -672,7 +672,7 @@ process.on('uncaughtException', err => {
user = [_cachedRow];
} else {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
@@ -875,13 +875,28 @@ process.on('uncaughtException', err => {
}
});
// CSRF validation helper — used by route handlers that have already populated req.session
// NOTE: Cannot be used in flummpress app.use() middlewares for upload/avatar bypass handlers
// because flummpress runs ALL middlewares in parallel (Promise.all), so the session
// middleware hasn't finished by the time these run. Those handlers validate CSRF inline.
const validateCsrf = (req, res) => {
// Intercept app.readBody to allow memoized reading of request body.
// flummpress runs app.use() in parallel via Promise.all before routing/body-reading,
// so validateCsrf needs to be able to read req.post without breaking subsequent router handler body parsing.
const _originalReadBody = app.readBody.bind(app);
app.readBody = async (req) => {
if (req.post !== undefined) return req.post;
return await _originalReadBody(req);
};
// CSRF validation helper — used by route handlers and global middleware
const validateCsrf = async (req, res) => {
if (req.session && req.session.csrf_token) {
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
let token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
// If header/query token is missing and body is not parsed yet on a non-GET method, parse it now
if (!token && req.post === undefined && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
try {
req.post = await app.readBody(req);
token = req.post?.csrf_token;
} catch (e) {}
}
if (!token || token !== req.session.csrf_token) {
console.error(`[CSRF] Blocked ${req.method} ${req.url.pathname} for user ${req.session.user}. Reason: ${!token ? 'Missing token' : 'Token mismatch'}`);
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' }));
@@ -903,7 +918,7 @@ process.on('uncaughtException', err => {
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
// User hall image upload is handled by bypass middleware below
if (cfg.websrv.userhalls_enabled !== false && cfg.websrv.enable_userhall_image_upload !== false && req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return;
if (!validateCsrf(req, res)) return;
if (!(await validateCsrf(req, res))) return;
});
// Bypass middleware for direct upload handling
@@ -1362,6 +1377,9 @@ process.on('uncaughtException', err => {
default_font: cfg.websrv.default_font || "",
site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl,
enable_private_uploads: cfg.enable_private_uploads !== false,
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
nsfl_tag_id: cfg.nsfl_tag_id || 3,
scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'],
themes_json: JSON.stringify(cfg.websrv.themes || []),

View File

@@ -78,6 +78,7 @@ export async function handleKoepfeUpload(req, res) {
throw new Error('Unsupported format');
}
await fs.mkdir(cfg.paths.koepfe, { recursive: true });
const newName = crypto.randomBytes(8).toString('hex') + ext;
const targetPath = path.join(cfg.paths.koepfe, newName);

View File

@@ -43,6 +43,10 @@ db`ALTER TABLE items ALTER COLUMN checksum TYPE character varying(255)`.catch(()
db`ALTER TABLE items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
db`ALTER TABLE comment_files ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
// One-time migration: fix NULL visibility values and ensure NOT NULL default going forward
db`UPDATE items SET visibility = 0 WHERE visibility IS NULL`.catch(() => {});
db`ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0`.catch(() => {});
export const handleUpload = async (req, res, self) => {
// Manual session lookup is required here because this handler is called from a
// bypass middleware that runs in parallel with the main session middleware.
@@ -138,6 +142,39 @@ export const handleUpload = async (req, res, self) => {
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
// Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference
let targetVisibility = 0;
if (cfg.enable_private_uploads !== false) {
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
? cfg.default_upload_visibility
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
if (!allowUserOverride) {
targetVisibility = sysDefault;
} else {
const rawVisHeader = req.headers['x-upload-visibility'];
const rawVisBody = parts.visibility;
const visVal = (rawVisHeader || rawVisBody || '').toString().trim().toLowerCase();
if (visVal === 'private' || visVal === '2') {
targetVisibility = 2;
} else if (visVal === 'unlisted' || visVal === '1') {
targetVisibility = 1;
} else if (visVal === 'public' || visVal === '0') {
targetVisibility = 0;
} else {
targetVisibility = (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
? req.session.default_upload_visibility
: sysDefault;
}
}
}
// Generate slug if enabled
const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null;
const maxLen = cfg.main.comment_max_length;
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
@@ -456,8 +493,10 @@ export const handleUpload = async (req, res, self) => {
original_filename: originalFilename,
title: title,
width: itemWidth,
height: itemHeight
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height')}
height: itemHeight,
visibility: targetVisibility,
slug: itemSlug
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug')}
`;
const itemid = await queue.getItemID(filename);
@@ -678,33 +717,35 @@ export const handleUpload = async (req, res, self) => {
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
// Discord Webhook
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => { });
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
reqDiscord.write(payload);
reqDiscord.end();
// Discord Webhook (only for public uploads)
if (targetVisibility === 0) {
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => { });
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
}
} catch (err) {
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
}
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!manualApproval) {
// Broadcast new_item event for live grid updates (only if auto-approved and public)
if (!manualApproval && targetVisibility === 0) {
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
@@ -720,20 +761,22 @@ export const handleUpload = async (req, res, self) => {
}
}
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
const clients = await Promise.all(self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
// Push to Matrix Channel (only if auto-approved and public)
if (!manualApproval && targetVisibility === 0) {
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
const clients = await Promise.all(self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
}
}
} catch (err) {
console.error('[UPLOAD] Matrix notification error:', err);
}
} catch (err) {
console.error('[UPLOAD] Matrix notification error:', err);
}
// Staff Notifications
@@ -768,13 +811,16 @@ export const handleUpload = async (req, res, self) => {
: 'Upload successful! Your upload is now live.';
const imagesPath = cfg.websrv.paths?.images || '/b';
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
return sendJson(res, {
success: true,
msg: successMsg,
itemid: itemid,
slug: itemSlug,
visibility: targetVisibility,
manual_approval: manualApproval,
redirect: !manualApproval ? `/${itemid}` : null,
url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : `${cfg.main.url.full}/`,
redirect: !manualApproval ? itemRoute : null,
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
// Fields for immediate client-side grid injection (avoids SSE race condition)
dest: filename,

View File

@@ -117,18 +117,20 @@
status.style.color = 'var(--accent)';
try {
const csrfToken = window.f0ckSession?.csrf_token || '{{ csrf_token }}';
const res = await fetch('/admin/settings', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded'
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
},
body: new URLSearchParams({
manual_approval: approvalToggle.checked ? 'on' : 'off',
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
min_tags: minTagsInput.value,
trusted_uploads: trustedUploadsInput.value,
csrf_token: '{{ csrf_token }}'
csrf_token: csrfToken
}).toString()
});

View File

@@ -3,7 +3,7 @@
@include(snippets/page-title)
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
@each(items as item)
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<a href="{{ link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>

View File

@@ -6,7 +6,7 @@
<div class="item-main-content">
<div class="_204863">
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="location">{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div>
</div>
@if(enable_item_title)
@@ -102,7 +102,7 @@
<div class="user-infobox-username-container">
<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="user-infobox-username">{!! item.author_display_name || item.username !!}</a>
</div>
<span class="user-infobox-timestamp"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
<span class="user-infobox-timestamp"><a href="/{{ item.slug || item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
</div>
<div class="user-infobox-body">
<div class="user-infobox-description">
@@ -111,9 +111,9 @@
<div class="user-infobox-actions">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
<span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
@@ -125,13 +125,13 @@
<span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link" @if(user_alternative_infobox)style="display:none"@endif>{{ item.id }}</a>
<a href="/{{ item.slug || item.id }}" class="id-link" data-item-id="{{ item.id }}" @if(user_alternative_infobox)style="display:none"@endif>{{ item.slug || item.id }}</a>
@if(!user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
@if(!user_alternative_infobox)
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
@endif
</span>
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(halls_enabled && item.primaryHall) — @endif @endif
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ item.slug || item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(halls_enabled && item.primaryHall) — @endif @endif
@if(halls_enabled && item.primaryHall)
<span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
@@ -143,9 +143,9 @@
@if(session)
@if(!user_alternative_infobox)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@@ -155,6 +155,7 @@
<i class="iconset fa-solid fa-layer-group" id="a_hall" data-item-id="{{ item.id }}" data-halls="{{ halls_slugs }}" data-user-halls="{{ user_halls_slugs }}" data-current-hall="{{ (tmp.hall && typeof tmp.hall === 'object') ? tmp.hall.slug : (tmp.hall || '') }}" data-current-user-hall="{{ (tmp.userHall && typeof tmp.userHall === 'object') ? tmp.userHall.slug : (tmp.userHall || '') }}" data-current-user-hall-owner="{{ tmp.userHallOwner || '' }}" title="Add to Hall"></i>
@endif
@if(can_manage_item)
<i class="iconset fa-solid fa-eye" id="a_visibility" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="Visibility: {{ item.visibility === 2 ? 'Private' : (item.visibility === 1 ? 'Unlisted' : 'Public') }} (Click to change)"></i>
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
@if(can_extract_meta)
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
@@ -197,7 +198,7 @@
</span>
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
@each(item.favorites as fav)
@if(fav.hide_fav_badge)
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@@ -255,6 +256,27 @@
<td>{!! item.title !!}</td>
</tr>
@endif
<tr class="info-id-row">
<th>ID</th>
<td><code>{{ item.id }}</code></td>
</tr>
<tr class="info-visibility-row">
<th>Visibility</th>
<td>
<span id="info-visibility-label" style="display: inline-flex; align-items: center; gap: 6px;">
@if(item.visibility === 2)
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
@elseif(item.visibility === 1)
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
@else
<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public
@endif
</span>
@if(can_manage_item)
<button id="info-visibility-edit-btn" class="btn-secondary btn-sm" style="margin-left: 8px; padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}"><i class="fa-solid fa-pen"></i> Edit</button>
@endif
</td>
</tr>
<tr>
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
<td>{{ item.size }}</td>
@@ -272,7 +294,7 @@
@if(item.checksum)
<tr>
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
<td><pre class="info-hash-codeblock"><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></pre></td>
</tr>
@endif
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
@@ -281,9 +303,9 @@
<td>
@each(item.reposts as rp)
@if(rp.match_type === 'phash')
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.slug || rp.id }}</a>
@else
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.slug || rp.id }}</a>
@endif
@endeach
</td>
@@ -310,3 +332,43 @@
</div>
</div>
</div>
<div id="visibility-modal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="max-width: 420px;">
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
</div>
<div class="modal-body" style="padding: 10px 0; text-align: left;">
<form id="visibility-form" onsubmit="return false;">
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
</div>
</label>
</div>
</form>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
</div>
</div>
</div>

View File

@@ -58,7 +58,7 @@
<div class="item-main-content">
<div class="_204863">
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="location">{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div>
</div>
@if(enable_item_title)
@@ -119,25 +119,26 @@
</div>
<div class="blahlol">
<span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
<a href="/{{ item.slug || item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ item.slug || item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
</span>@if(halls_enabled && item.primaryHall) — @endif
@if(halls_enabled && item.primaryHall)
<span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
</span>
@endif
<span class="badge badge-dark"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
<span class="badge badge-dark"><a href="/{{ item.slug || item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
<div class="gapRight">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(can_manage_item)
<i class="iconset fa-solid fa-eye" id="a_visibility" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="Visibility: {{ item.visibility === 2 ? 'Private' : (item.visibility === 1 ? 'Unlisted' : 'Public') }} (Click to change)"></i>
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
@if(is_flash_item)
@@ -157,7 +158,7 @@
</div>
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
@each(item.favorites as fav)
@if(fav.hide_fav_badge)
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@@ -197,6 +198,27 @@
<td>{!! item.title !!}</td>
</tr>
@endif
<tr class="info-id-row">
<th>ID</th>
<td><code>{{ item.id }}</code></td>
</tr>
<tr class="info-visibility-row">
<th>Visibility</th>
<td>
<span id="info-visibility-label" style="display: inline-flex; align-items: center; gap: 6px;">
@if(item.visibility === 2)
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
@elseif(item.visibility === 1)
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
@else
<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public
@endif
</span>
@if(can_manage_item)
<button id="info-visibility-edit-btn" class="btn-secondary btn-sm" style="margin-left: 8px; padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}"><i class="fa-solid fa-pen"></i> Edit</button>
@endif
</td>
</tr>
<tr>
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
<td>{{ item.size }}</td>
@@ -214,7 +236,7 @@
@if(item.checksum)
<tr>
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
<td><pre class="info-hash-codeblock"><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></pre></td>
</tr>
@endif
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
@@ -223,9 +245,9 @@
<td>
@each(item.reposts as rp)
@if(rp.match_type === 'phash')
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.slug || rp.id }}</a>
@else
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
<a href="/{{ rp.slug || rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.slug || rp.id }}</a>
@endif
@endeach
</td>
@@ -252,3 +274,43 @@
</div>
</div>
</div>
<div id="visibility-modal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="max-width: 420px;">
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
</div>
<div class="modal-body" style="padding: 10px 0; text-align: left;">
<form id="visibility-form" onsubmit="return false;">
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
</div>
</label>
</div>
</form>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
</div>
</div>
</div>

View File

@@ -94,7 +94,7 @@
<tbody>
@each(favotop as favo)
<tr>
<td><a href="/{{ favo.item_id }}">#{{ favo.item_id }}</a></td>
<td><a href="/{{ favo.slug || favo.id }}">#{{ favo.slug || favo.id }}</a></td>
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
</tr>
@endeach
@@ -109,7 +109,7 @@
<tbody>
@each(xdtop as item)
<tr>
<td><a href="/{{ item.id }}">#{{ item.id }}</a></td>
<td><a href="/{{ item.slug || item.id }}">#{{ item.slug || item.id }}</a></td>
<td>
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>

View File

@@ -334,6 +334,19 @@
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
</div>
</div>
@if(allow_user_upload_visibility !== false)
<div class="setting-item" style="margin-top: 15px;">
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
<span>{{ t('settings.default_upload_visibility') }}</span>
</label>
<select id="default_upload_visibility_select" class="form-control" style="width: 200px; padding: 5px; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--nav-border-color); border-radius: 4px;">
<option value="0" @if(session.default_upload_visibility === 0 || (!session.default_upload_visibility && session.default_upload_visibility !== 0)) selected @endif>{{ t('visibility.public') }}</option>
<option value="1" @if(session.default_upload_visibility === 1) selected @endif>{{ t('visibility.unlisted') }}</option>
<option value="2" @if(session.default_upload_visibility === 2) selected @endif>{{ t('visibility.private') }}</option>
</select>
<small class="text-muted" style="display: block; margin-top: 5px;">{{ t('settings.default_upload_visibility_hint') }}</small>
</div>
@endif
<div class="setting-item" style="margin-top: 15px;">
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>

View File

@@ -2,7 +2,7 @@
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
<head>
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ item.id }}</title>@else<title>{{ domain }}</title>@endif
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ item.slug || item.id }}</title>@else<title>{{ domain }}</title>@endif
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0096ff">
<meta name="mobile-web-app-capable" content="yes">
@@ -72,18 +72,18 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
@if(typeof item !== 'undefined')
<link rel="canonical" href="https://{{ domain }}/{{ item.id }}" />
<link rel="canonical" href="https://{{ domain }}/{{ item.slug || item.id }}" />
<meta property="og:site_name" content="{{ domain }}" />
<meta property="og:title" content="{{ item.id }}" />
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" />
<meta property="og:title" content="{{ item.slug || item.id }}" />
<meta property="og:url" content="https://{{ domain }}/{{ item.slug || item.id }}" />
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
<meta name="description" content="{{ site_description }}" />
<meta property="og:description" content="{{ site_description }}" />
<meta property="og:type" content="website" />
<meta property="twitter:card" content="summary" />
<meta property="twitter:title" content="{{ item.id }}" />
<meta property="twitter:title" content="{{ item.slug || item.id }}" />
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
<meta property="twitter:url" content="https://{{ domain }}/{{ item.id }}" />
<meta property="twitter:url" content="https://{{ domain }}/{{ item.slug || item.id }}" />
@else
<meta property="og:site_name" content="{{ domain }}" />
<meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" />

View File

@@ -1,5 +1,5 @@
@each(items as item)
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<a href="{{ link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>

View File

@@ -1,12 +1,12 @@
@each(notifications as n)
@if(n.type === 'approve')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/>
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_id) }}</div>
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_slug || n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@@ -17,7 +17,7 @@
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div>
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_id) }}</div>
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_slug || n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@@ -33,46 +33,46 @@
</div>
</a>
@elseif(n.type === 'deny')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg">
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_id) }}</strong>
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_slug || n.item_id) }}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'item_deleted')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
<div class="notif-msg">
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_id) }}</strong>
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_slug || n.item_id) }}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'upload_comment')
<a href="/{{ n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'">
</div>
<div class="notif-content">
<div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div>
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_id) }}</div>
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_slug || n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'upload_success')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
</div>
@@ -83,7 +83,7 @@
</div>
</a>
@elseif(n.type === 'upload_error')
<a href="{{ n.item_id ? '/' + n.item_id : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
<a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
@if(n.item_id)
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
@@ -116,7 +116,7 @@
</div>
</div>
@else
<a href="/{{ n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
@if(n.item_id)
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
@@ -125,9 +125,9 @@
<div class="notif-content">
<div class="notif-user"><strong @if(n.username_color) style="color: {{ n.username_color }}" @endif>{!! n.from_user !!}</strong></div>
<div class="notif-msg">
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_id) }}
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_id) }}
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_id) }}
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_slug || n.item_id) }}
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_slug || n.item_id) }}
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_slug || n.item_id) }}
@endif
</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>

View File

@@ -1,6 +1,6 @@
@each(items as item)
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
<a href="/{{ item.id }}" class="sub-link">
<a href="/{{ item.slug || item.id }}" class="sub-link">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@@ -8,7 +8,7 @@
</div>
<img src="{{ item.thumb }}" loading="lazy" />
<div class="sub-info">
<span class="sub-id">#{{ item.id }}</span>
<span class="sub-id">#{{ item.slug || item.id }}</span>
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
</div>
</a>

View File

@@ -79,6 +79,26 @@
</div>
</div>
@if(enable_private_uploads !== false && allow_user_upload_visibility !== false)
<div class="form-section global-visibility-section">
<label>{{ t('upload.visibility') || 'Visibility' }}</label>
<div class="visibility-options rating-options">
<label class="rating-option">
<input type="radio" name="visibility" value="0" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 0) ? 'checked' : '' }}>
<span class="rating-label sfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-globe"></i> {{ t('common.public') || 'Public' }}</span>
</label>
<label class="rating-option">
<input type="radio" name="visibility" value="1" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 1) ? 'checked' : '' }}>
<span class="rating-label nsfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-link"></i> {{ t('common.unlisted') || 'Unlisted' }}</span>
</label>
<label class="rating-option">
<input type="radio" name="visibility" value="2" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 2) ? 'checked' : '' }}>
<span class="rating-label nsfl" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-lock"></i> {{ t('common.private') || 'Private' }}</span>
</label>
</div>
</div>
@endif
<div class="form-section global-tag-section">
<label>

View File

@@ -123,7 +123,7 @@
@if(count.f0cks)
<div class="posts no-infinite-scroll">
@each(f0cks.items as item)
<a href="{{ f0cks.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<a href="{{ f0cks.link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@@ -156,7 +156,7 @@
@if(count.favs)
<div class="posts no-infinite-scroll">
@each(favs.items as item)
<a href="{{ favs.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<a href="{{ favs.link.main }}{{ item.slug || item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>