@@ -18812,6 +19054,113 @@ class NotificationSystem {
`;
}
+ markItemRead(itemId, slug = null) {
+ if (!itemId && !slug) return;
+ const strId = itemId ? String(itemId) : null;
+ const strSlug = slug ? String(slug) : null;
+
+ const matchesItem = (n) => {
+ if (!n) return false;
+ if (strId && String(n.item_id) === strId) return true;
+ if (strSlug && (String(n.item_slug) === strSlug || String(n.slug) === strSlug)) return true;
+ return false;
+ };
+
+ [this._cachedUser, this._cachedSystem].forEach(list => {
+ if (!Array.isArray(list)) return;
+ list.forEach(n => {
+ if (matchesItem(n) && !n.is_read) {
+ n.is_read = true;
+ }
+ });
+ });
+
+ // Remove matching .notif-item elements from dropdown
+ if (this.list) {
+ const items = this.list.querySelectorAll('.notif-item');
+ items.forEach(el => {
+ const notifItemId = el.dataset.itemId;
+ const notifItemSlug = el.dataset.itemSlug;
+ const notifHref = el.getAttribute('href') || '';
+ const isMatch = (strId && notifItemId === strId) ||
+ (strSlug && notifItemSlug === strSlug) ||
+ (strId && (notifHref === `/${strId}` || notifHref.startsWith(`/${strId}#`) || notifHref.startsWith(`/${strId}?`))) ||
+ (strSlug && (notifHref === `/${strSlug}` || notifHref.startsWith(`/${strSlug}#`) || notifHref.startsWith(`/${strSlug}?`)));
+ if (isMatch) {
+ el.remove();
+ }
+ });
+ if (this.list.children.length === 0) {
+ this.list.innerHTML = `
${window.f0ckI18n?.no_notifications || 'No new notifications'}
`;
+ }
+ }
+
+ // Mark as read in full history page if open
+ document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => {
+ const notifItemId = el.dataset.itemId;
+ const notifItemSlug = el.dataset.itemSlug;
+ const notifHref = el.getAttribute('href') || '';
+ const isMatch = (strId && notifItemId === strId) ||
+ (strSlug && notifItemSlug === strSlug) ||
+ (strId && (notifHref === `/${strId}` || notifHref.startsWith(`/${strId}#`) || notifHref.startsWith(`/${strId}?`))) ||
+ (strSlug && (notifHref === `/${strSlug}` || notifHref.startsWith(`/${strSlug}#`) || notifHref.startsWith(`/${strSlug}?`)));
+ if (isMatch) {
+ el.classList.remove('unread');
+ }
+ });
+
+ // Recalculate unread counts
+ const userUnread = (this._cachedUser || []).filter(n => !n.is_read).length;
+ const systemUnread = (this._cachedSystem || []).filter(n => !n.is_read).length;
+ const totalUnread = userUnread + systemUnread;
+
+ if (this.countBadge) {
+ this.countBadge.textContent = totalUnread;
+ this.countBadge.style.display = totalUnread > 0 ? 'block' : 'none';
+ }
+ const userBadge = document.getElementById('notif-tab-badge-user');
+ const systemBadge = document.getElementById('notif-tab-badge-system');
+ if (userBadge) {
+ userBadge.textContent = userUnread;
+ userBadge.style.display = userUnread > 0 ? '' : 'none';
+ }
+ if (systemBadge) {
+ systemBadge.textContent = systemUnread;
+ systemBadge.style.display = systemUnread > 0 ? '' : 'none';
+ }
+ if (typeof window._scrollerNotifHook === 'function') {
+ window._scrollerNotifHook(totalUnread);
+ }
+
+ // Remove .has-notif from matching thumbnails in DOM
+ const removeHasNotif = (root = document) => {
+ root.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => {
+ const id = el.dataset.itemId || el.getAttribute('data-item-id') || el.getAttribute('href')?.match(/\/([a-zA-Z0-9_-]{11}|\d+)(?:[?#]|$)/)?.[1];
+ if (id && (id === strId || id === strSlug)) {
+ el.classList.remove('has-notif');
+ }
+ });
+ };
+ removeHasNotif(document);
+
+ // Also strip from any detached cached grids in gridCacheMap
+ if (typeof gridCacheMap !== 'undefined' && gridCacheMap) {
+ gridCacheMap.forEach(cacheEntry => {
+ if (cacheEntry && cacheEntry.node) {
+ removeHasNotif(cacheEntry.node);
+ }
+ });
+ }
+
+ // Tell server in background (fire-and-forget with keepalive)
+ const serverId = strId || strSlug;
+ if (serverId) {
+ fetch(`/api/notifications/item/${encodeURIComponent(serverId)}/read`, {
+ method: 'POST',
+ keepalive: true
+ }).catch(() => {});
+ }
+ }
async markAllRead() {
try {
@@ -18829,7 +19178,7 @@ class NotificationSystem {
}
markAllReadUI() {
- this.countBadge.style.display = 'none';
+ if (this.countBadge) this.countBadge.style.display = 'none';
// Clear per-tab badges
const userBadge = document.getElementById('notif-tab-badge-user');
const systemBadge = document.getElementById('notif-tab-badge-system');
@@ -18843,6 +19192,16 @@ class NotificationSystem {
}
// Also update History page items if present
document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => el.classList.remove('unread'));
+ // Remove .has-notif highlights
+ document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif'));
+ if (typeof gridCacheMap !== 'undefined' && gridCacheMap) {
+ gridCacheMap.forEach(cacheEntry => {
+ if (cacheEntry?.node) cacheEntry.node.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif'));
+ });
+ }
+ if (typeof window._scrollerNotifHook === 'function') {
+ window._scrollerNotifHook(0);
+ }
}
handleTagsUpdate(data) {
@@ -19053,6 +19412,8 @@ class NotificationSystem {
return false;
});
favoBtns.forEach(favoBtn => {
+ favoBtn.classList.remove('fa-spinner', 'fa-spin');
+ favoBtn.classList.add('iconset', 'fa-heart');
favoBtn.classList.toggle('fa-solid', isNowFav);
favoBtn.classList.toggle('fa-regular', !isNowFav);
});
diff --git a/public/s/js/user.js b/public/s/js/user.js
index a31d01c..1710fc7 100644
--- a/public/s/js/user.js
+++ b/public/s/js/user.js
@@ -257,17 +257,34 @@
return;
}
const favoBtns = document.querySelectorAll("#a_favo");
+ if (!favoBtns.length) return;
const firstFavoBtn = favoBtns[0];
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
let localId = firstFavoBtn?.dataset?.localId;
+ const wasAlreadyFav = favoBtns[0].classList.contains('fa-solid') && !favoBtns[0].classList.contains('fa-spinner');
+
+ const setFavoUi = (isFav) => {
+ favoBtns.forEach(btn => {
+ btn.classList.remove('fa-spinner', 'fa-spin');
+ btn.classList.add('iconset', 'fa-heart');
+ btn.classList.toggle('fa-solid', isFav);
+ btn.classList.toggle('fa-regular', !isFav);
+ });
+ };
+
+ const setFavoLoading = () => {
+ favoBtns.forEach(btn => {
+ btn.classList.remove('fa-heart');
+ btn.classList.add('iconset', 'fa-solid', 'fa-spinner', 'fa-spin');
+ });
+ };
+
// If viewing un-rehosted 4chan post, auto-rehost first
if (isChan && !localId && chanUrl) {
- favoBtns.forEach(btn => {
- btn.className = 'iconset fa-solid fa-spinner fa-spin';
- });
+ setFavoLoading();
try {
const csrfToken = window.f0ckSession?.csrf_token || '';
const rehostResp = await fetch('/api/v2/scroller/rehost', {
@@ -295,6 +312,15 @@
const infoEl = document.querySelector("#a_info");
if (infoEl) infoEl.dataset.itemId = localId;
+ const timeEl = document.querySelector('time.timeago');
+ if (timeEl) {
+ const nowIso = new Date().toISOString();
+ timeEl.dataset.iso = nowIso;
+ const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
+ timeEl.setAttribute('tooltip', fullDate);
+ timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
+ }
+
if (chanRehostBtn) {
chanRehostBtn.classList.add('rehosted');
chanRehostBtn.outerHTML = `
@@ -309,9 +335,7 @@
}
} catch (err) {
console.error('[CHAN-FAV] Auto-rehost error:', err);
- favoBtns.forEach(btn => {
- btn.className = 'iconset fa-regular fa-heart';
- });
+ setFavoUi(wasAlreadyFav);
if (typeof window.flashMessage === 'function') {
window.flashMessage('Failed to favorite post', 3000, 'error');
}
@@ -320,11 +344,12 @@
}
const ctx = getContext();
- if (!ctx) return;
+ if (!ctx) {
+ setFavoUi(wasAlreadyFav);
+ return;
+ }
const postid = localId ? Number(localId) : ctx.postid;
- const wasAlreadyFav = favoBtns.length > 0 && favoBtns[0].classList.contains('fa-solid');
-
try {
const res = await post('/api/v2/togglefav', {
postid: postid,
@@ -337,10 +362,7 @@
// Authoritative server state takes priority; fallback to inverting previous UI state
const isNowFav = (res.favorited !== undefined) ? !!res.favorited : !wasAlreadyFav;
- favoBtns.forEach(favoBtn => {
- favoBtn.classList.toggle('fa-solid', isNowFav);
- favoBtn.classList.toggle('fa-regular', !isNowFav);
- });
+ setFavoUi(isNowFav);
// span#favs
const favcontainer = document.querySelector('#favs');
@@ -412,12 +434,14 @@
if (navigator.vibrate) navigator.vibrate(50);
}
else {
+ setFavoUi(wasAlreadyFav);
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
}
}
} catch (err) {
+ setFavoUi(wasAlreadyFav);
if (typeof window.flashMessage === 'function') {
window.flashMessage('Failed to update favorite.', 3000, 'error');
}
diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs
index a6d9604..264605c 100644
--- a/src/inc/routeinc/f0cklib.mjs
+++ b/src/inc/routeinc/f0cklib.mjs
@@ -1476,7 +1476,7 @@ const f0cklib = {
]);
console.log(`[GETF0CK_OPT] All queries finished in ${Date.now() - runTimings}ms`);
- const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: false });
+ const link = lib.genLink({ user, tag, hall: (hall && typeof hall === 'object') ? hall.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: '', strict: strict });
// Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) {
link.main = `/tag/title:${encodeURIComponent(titleQuery)}/`;
diff --git a/src/inc/routes/chan.mjs b/src/inc/routes/chan.mjs
index 182f2a2..92d7f6e 100644
--- a/src/inc/routes/chan.mjs
+++ b/src/inc/routes/chan.mjs
@@ -170,13 +170,19 @@ export default (router, tpl) => {
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
+ const rehostMap = {};
if (cdnUrls.length > 0) {
try {
- const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
+ const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
+ const info = { id: r.id, stamp: r.stamp };
rehosts[r.src] = r.id;
+ rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/);
- if (m) rehosts[m[1]] = r.id;
+ if (m) {
+ rehosts[m[1]] = r.id;
+ rehostMap[m[1]] = info;
+ }
});
} catch (e) {
console.error('[CHAN] Rehost check error:', e.message);
@@ -199,7 +205,10 @@ export default (router, tpl) => {
const isVideo = ['.webm', '.mp4'].includes(ext);
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
const externalMediaUrl = p.tim ? `https://i.4cdn.org/${board}/${p.tim}${ext}` : null;
- const localId = (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null;
+ const rehostInfo = (p.tim && rehostMap[p.tim]) || (externalMediaUrl && rehostMap[externalMediaUrl]) || null;
+ const localId = rehostInfo ? rehostInfo.id : null;
+ const effectiveTime = (localId && rehostInfo?.stamp) ? Number(rehostInfo.stamp) : p.time;
+ const isoStr = new Date(effectiveTime * 1000).toISOString();
return {
no: p.no,
@@ -208,9 +217,10 @@ export default (router, tpl) => {
com_formatted: formatComment(p.com || ''),
name: p.name || 'Anonymous',
trip: p.trip || '',
- time: p.time,
- timeago: lib.timeAgo(p.time * 1000, req.lang || 'en'),
- date_str: p.now || new Date(p.time * 1000).toLocaleString(),
+ time: effectiveTime,
+ timeago: lib.timeAgo(effectiveTime * 1000, req.lang || 'en'),
+ date_str: (localId && rehostInfo?.stamp) ? new Date(effectiveTime * 1000).toLocaleString() : (p.now || new Date(effectiveTime * 1000).toLocaleString()),
+ iso_str: isoStr,
has_media: !!(p.tim && p.ext),
tim: p.tim,
ext: ext,
@@ -240,6 +250,7 @@ export default (router, tpl) => {
first_media_no: firstMediaPost ? firstMediaPost.no : null,
rehosts_count: Object.keys(rehosts).length,
session: req.session ? { ...req.session } : false,
+ csrf_token: req.session?.csrf_token || '',
domain: cfg.main.url.domain,
tmp: null,
page_meta: {
@@ -306,7 +317,7 @@ export default (router, tpl) => {
const rehosts = {};
if (cdnUrls.length > 0) {
try {
- const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
+ const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
@@ -333,13 +344,15 @@ export default (router, tpl) => {
let localDest = null;
let localThumb = null;
let localTitle = null;
+ let localStamp = null;
let itemFavorites = [];
let userHasFavorited = false;
if (localId) {
try {
- const localItem = await db`SELECT id, username, dest, title, mime, width, height FROM items WHERE id = ${localId} LIMIT 1`;
+ const localItem = await db`SELECT id, username, dest, title, mime, width, height, stamp FROM items WHERE id = ${localId} LIMIT 1`;
if (localItem.length > 0) {
const li = localItem[0];
+ localStamp = li.stamp;
if (li.title) localTitle = li.title;
if (li.dest) {
localDest = `/b/${li.dest}`;
@@ -368,6 +381,7 @@ export default (router, tpl) => {
}
// Construct item matching f0ckm's standard item view expectations
+ const effectiveStamp = (localId && localStamp) ? Number(localStamp) : targetPost.time;
const item = {
id: targetPost.no,
slug: null,
@@ -379,10 +393,10 @@ export default (router, tpl) => {
height: targetPost.h || null,
size: targetPost.fsize ? lib.formatSize(targetPost.fsize) : '0 B',
username: targetPost.name || 'Anonymous',
- stamp: targetPost.time,
+ stamp: effectiveStamp,
timestamp: {
- timeago: lib.timeAgo(targetPost.time * 1000, req.lang || 'en'),
- timefull: new Date(targetPost.time * 1000).toISOString()
+ timeago: lib.timeAgo(effectiveStamp * 1000, req.lang || 'en'),
+ timefull: new Date(effectiveStamp * 1000).toISOString()
},
comment: targetPost.com ? targetPost.com.replace(/
/gi, '\n').replace(/<[^>]+>/g, '') : '',
is_chan: true,
@@ -466,6 +480,7 @@ export default (router, tpl) => {
link,
chan_thread: chanThreadMeta,
session: req.session ? { ...req.session } : false,
+ csrf_token: req.session?.csrf_token || '',
domain: cfg.main.url.domain,
tmp: null,
hidePagination: true,
diff --git a/src/inc/routes/external.mjs b/src/inc/routes/external.mjs
index aa9a890..3e07a13 100644
--- a/src/inc/routes/external.mjs
+++ b/src/inc/routes/external.mjs
@@ -618,7 +618,7 @@ export default (router) => {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
}
- const { item_id, rating, tags, comment } = req.post || {};
+ const { item_id, rating, tags, comment } = req.post || req.body || {};
const itemId = parseInt(item_id, 10);
if (!itemId || isNaN(itemId)) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Valid item_id is required' }) });
@@ -636,11 +636,12 @@ export default (router) => {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
}
+ const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
+ const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
+
// 1. Update Rating if provided
if (rating !== undefined && rating !== null && rating !== '') {
const r = String(rating).toLowerCase().trim();
- const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
- const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
await db`
DELETE FROM tags_assign
@@ -658,22 +659,30 @@ export default (router) => {
// If r === 'untagged', no rating tag is inserted
}
- // 2. Add Tags if provided
- const rawList = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
- const cleanTags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
- for (const tagName of cleanTags) {
- let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
- if (tagRow.length === 0) {
- await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')} ON CONFLICT DO NOTHING`;
- tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
+ // 2. Sync Tags if provided
+ if (tags !== undefined && tags !== null) {
+ const rawList = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
+ const cleanTags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
+
+ // Delete existing non-rating tags for this item by this user (or all non-rating tags if owner)
+ await db`
+ DELETE FROM tags_assign
+ WHERE item_id = ${itemId}
+ AND tag_id NOT IN (1, 2, ${nsflId})
+ AND tag_id NOT IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))
+ ${isOwner ? db`` : db`AND user_id = ${session.id}`}
+ `;
+
+ for (const tagName of cleanTags) {
+ let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
+ if (tagRow.length === 0) {
+ await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')} ON CONFLICT DO NOTHING`;
+ tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
+ }
+ if (tagRow.length) {
+ await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: tagRow[0].id, user_id: session.id })} ON CONFLICT DO NOTHING`;
+ }
}
- if (tagRow.length) {
- await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: tagRow[0].id, user_id: session.id })} ON CONFLICT DO NOTHING`;
- }
- }
- if (cleanTags.length > 0) {
- const freshTags = await lib.getTags(itemId, session);
- await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags }));
}
// 3. Add Comment if provided
@@ -689,7 +698,15 @@ export default (router) => {
}
const freshTags = await lib.getTags(itemId, session);
+ await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags })).catch(() => {});
+
const ratingTag = freshTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
+ const cleanTagObjects = freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => ({
+ id: t.id,
+ tag: t.tag,
+ normalized: t.normalized,
+ badge: t.badge
+ }));
return res.reply({
headers: { 'Content-Type': 'application/json' },
@@ -697,7 +714,7 @@ export default (router) => {
success: true,
item_id: itemId,
rating: ratingTag ? ratingTag.normalized : 'untagged',
- tags: freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag)
+ tags: cleanTagObjects
})
});
} catch (err) {
diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs
index 7152541..3a539ae 100644
--- a/src/inc/routes/notifications.mjs
+++ b/src/inc/routes/notifications.mjs
@@ -665,17 +665,19 @@ export default (router, tpl) => {
// Used when the user receives a live notification while already viewing that item.
// System-type notifications (item_deleted, deny, report, admin_pending) are excluded —
// they require explicit user acknowledgment.
- router.post(/\/api\/notifications\/item\/(?
\d+)\/read/, async (req, res) => {
- if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
- const itemId = req.params.itemId;
+ router.post(/\/api\/notifications\/item\/(?[a-zA-Z0-9_-]{11}|\d+)\/read/, async (req, res) => {
+ if (!req.session?.id) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
+ const rawItemId = req.params.itemId;
+ const numericId = await f0cklib.resolveNumericItemId(rawItemId);
+ if (!numericId) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report', 'warning'];
- console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
+ console.log(`[NotificationRoute] Marking notifications for item ${numericId} (${rawItemId}) as read for user ${req.session.id}`);
try {
await db`
UPDATE notifications
SET is_read = true
WHERE user_id = ${req.session.id}
- AND item_id = ${+itemId}
+ AND item_id = ${numericId}
AND NOT (type = ANY(${SYSTEM_TYPES}))
`;
return res.reply({
diff --git a/src/inc/routes/toptags.mjs b/src/inc/routes/toptags.mjs
index 9ef38d2..bd36020 100644
--- a/src/inc/routes/toptags.mjs
+++ b/src/inc/routes/toptags.mjs
@@ -144,7 +144,7 @@ export default (router, tpl) => {
});
// Main tags page
- router.get(/^\/tags$/, async (req, res) => {
+ router.get(/^\/tags?\/?$/, async (req, res) => {
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
const mode = req.mode ?? 0;
const query = req.url.qs || {};
diff --git a/views/admin.html b/views/admin.html
index 34c225e..f0275a6 100644
--- a/views/admin.html
+++ b/views/admin.html
@@ -268,6 +268,9 @@
}
preview.src = data.url;
+ // Immediately update navbar brand logo
+ if (window.updateBrandImage) window.updateBrandImage(data.url);
+
// Enable remove button
removeBtn.disabled = false;
removeBtn.style.cssText = 'background:rgba(220,53,69,0.15);border:1px solid rgba(220,53,69,0.4);color:#dc3545;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.82em;font-weight:bold;transition:background 0.2s;';
@@ -310,6 +313,9 @@
const wrap = document.getElementById('brand-preview-wrap');
wrap.innerHTML = 'No image set';
+ // Immediately update navbar brand logo (revert to text)
+ if (window.updateBrandImage) window.updateBrandImage(null);
+
// Disable remove button
removeBtn.disabled = true;
removeBtn.style.cssText = 'background:rgba(220,53,69,0.05);border:1px solid rgba(220,53,69,0.15);color:#884040;padding:7px 16px;border-radius:4px;cursor:not-allowed;font-size:0.82em;font-weight:bold;';
diff --git a/views/chan/thread.html b/views/chan/thread.html
index c146ac2..f7b2c7d 100644
--- a/views/chan/thread.html
+++ b/views/chan/thread.html
@@ -64,7 +64,7 @@
@if(p.trip)
{{ p.trip }}
@endif
-
+
No. {{ p.no }}
@if(p.local_id)
@@ -674,6 +674,20 @@
' Rehosted #' + data.item_id + '' +
'';
+
+ const postCard = document.getElementById('p' + postNo) || btn.closest('.chan-post-card');
+ if (postCard) {
+ const timeEl = postCard.querySelector('.chan-post-time, .timeago');
+ if (timeEl) {
+ const nowIso = new Date().toISOString();
+ timeEl.dataset.iso = nowIso;
+ const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
+ timeEl.setAttribute('tooltip', fullDate);
+ timeEl.setAttribute('title', fullDate);
+ timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
+ }
+ }
+
if (typeof window.showToast === 'function') {
window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!');
}
@@ -696,6 +710,7 @@
const editBtn = e.target.closest('.chan-rehost-edit-btn');
if (editBtn) {
e.preventDefault();
+ e.stopImmediatePropagation();
const itemId = editBtn.dataset.itemId;
if (itemId && typeof window.openChanRehostModal === 'function') {
window.openChanRehostModal({ itemId: Number(itemId), isPlayerView: false });
@@ -762,14 +777,25 @@
}
}
// Add thumb badge if missing
- const postCard = favBtn.closest('.chan-post');
- const thumbLink = postCard?.querySelector('.chan-thumb-link');
- if (thumbLink && !thumbLink.querySelector('.chan-thumb-rehost-badge')) {
- const badge = document.createElement('span');
- badge.className = 'chan-thumb-rehost-badge';
- badge.title = 'Rehosted on f0ckm as Post #' + localId;
- badge.innerHTML = ' #' + localId;
- thumbLink.appendChild(badge);
+ const postCard = document.getElementById('p' + postNo) || favBtn.closest('.chan-post-card');
+ if (postCard) {
+ const thumbLink = postCard.querySelector('.chan-thumb-link');
+ if (thumbLink && !thumbLink.querySelector('.chan-thumb-rehost-badge')) {
+ const badge = document.createElement('span');
+ badge.className = 'chan-thumb-rehost-badge';
+ badge.title = 'Rehosted on f0ckm as Post #' + localId;
+ badge.innerHTML = ' #' + localId;
+ thumbLink.appendChild(badge);
+ }
+ const timeEl = postCard.querySelector('.chan-post-time, .timeago');
+ if (timeEl) {
+ const nowIso = new Date().toISOString();
+ timeEl.dataset.iso = nowIso;
+ const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
+ timeEl.setAttribute('tooltip', fullDate);
+ timeEl.setAttribute('title', fullDate);
+ timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
+ }
}
} else {
throw new Error(rehostData.msg || 'Rehost failed');
diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html
index faf01ce..97d5111 100644
--- a/views/item-partial-legacy.html
+++ b/views/item-partial-legacy.html
@@ -1,12 +1,12 @@
{{-- LEGACY LAYOUT — 2-column (main + fixed right sidebar) --}}
{{-- Used by: members with use_new_layout = false --}}
-
+
{{-- MAIN CONTENT: media + navigation + metadata + comments --}}
@if(enable_item_title)
@@ -14,7 +14,7 @@
@if(item.title){!! item.title !!}@elseif(can_manage_item)Add a title@endif
@if(can_manage_item)
-
+
@@ -129,11 +129,11 @@
@if(item.is_chan)
@if(item.local_id)
- #{{ item.local_id }}
+ #{{ item.local_id }}
@else
-
+
@endif
@endif
@if(session)
@@ -190,9 +190,9 @@
@if(!user_alternative_infobox && item.is_chan) —
/{{ item.external_board }}/{{ item.external_tid }}
@if(item.local_id)
- #{{ item.local_id }}
+ #{{ item.local_id }}
@else
-
+
@endif
@endif
@@ -201,17 +201,17 @@
@endif
-
+
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
-
+
?
@endif
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
-
+
{!! tag.tag !!}
@else
@@ -256,7 +256,7 @@
@if(enable_comments && (session || !hide_comments_from_public))
@elseif(n.type === 'upload_error')
-
+
@if(n.item_id)
)||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp)
@@ -104,7 +104,7 @@
@elseif(n.type === 'warning')
-
+
{{ t('notifications.system') }}
@@ -116,7 +116,7 @@
@else
-
+
@if(n.item_id)