24 Commits

Author SHA1 Message Date
dfc8ebba89 adding comment overview page on userprofiles 2026-01-26 22:54:59 +01:00
002dfeded3 fixing emoji picker not being consistent 2026-01-26 22:21:14 +01:00
ca86cf7c6e possible tag deletion fix 2026-01-26 21:49:48 +01:00
734071beab possible fix for tag deletion modal 2026-01-26 20:53:08 +01:00
0e9fedc9b4 enhance error handling for register 2026-01-26 20:28:25 +01:00
8fe362c966 making better use of the notification system 2026-01-26 20:02:15 +01:00
8180cdd885 well 2026-01-26 19:34:47 +01:00
b71711aa96 smoother navbar black background when scrolled 2026-01-26 18:23:49 +01:00
588ab3c3de gdfsgfds 2026-01-26 10:21:44 +01:00
46fb7067ed revert 166ba96f0f
revert revert c4bb21bc31

revert possible fix for slow loading times for non logged in users
2026-01-26 09:15:37 +00:00
5815334e7b revert 6bfb200626
revert revert c4bb21bc31

revert possible fix for slow loading times for non logged in users
2026-01-26 09:15:17 +00:00
6bfb200626 revert c4bb21bc31
revert possible fix for slow loading times for non logged in users
2026-01-26 09:11:29 +00:00
166ba96f0f revert c4bb21bc31
revert possible fix for slow loading times for non logged in users
2026-01-26 09:10:48 +00:00
a2ebea2ba3 if this fails I revert 2026-01-26 10:09:11 +01:00
91546a1f0d lets try it out lmao 2026-01-26 10:04:53 +01:00
017ac4ca4c bruh 2026-01-26 10:00:59 +01:00
cb31cfd85b possible random performance improvement ? 2026-01-26 09:58:30 +01:00
a5acc22c4a possible performance improvement for random 2026-01-26 09:47:05 +01:00
dbcf39c3ba possible query optimization for random large datasets 2026-01-26 09:37:57 +01:00
4d2fd7561f possible performance optimization 2026-01-26 09:31:02 +01:00
c4bb21bc31 possible fix for slow loading times for non logged in users 2026-01-26 09:24:27 +01:00
7d58acd5ed visually enhancing comment section 2026-01-26 07:33:29 +01:00
286791de0d feat: Implement custom emojis, pinned comments, and comment locking with database schema changes and frontend rendering updates. 2026-01-25 23:01:31 +01:00
1b8a9185bb Merge pull request 'feat: Introduce custom emojis, pinned comments, and thread locking with database schema, client-side caching, and UI updates.' (#6) from comments-fix into f0bm
Reviewed-on: #6
2026-01-25 21:37:10 +00:00
20 changed files with 453 additions and 69 deletions

View File

@@ -0,0 +1 @@
CREATE INDEX CONCURRENTLY IF NOT EXISTS tags_assign_tag_id_idx ON public.tags_assign (tag_id);

View File

@@ -1596,7 +1596,8 @@ div.posts>a:hover::after {
} }
.navbar.scrolled { .navbar.scrolled {
background: #000 !important; background: black !important;
transition: background 0.1s ease;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
} }
@@ -3848,7 +3849,7 @@ input#s_avatar {
/* Comments System */ /* Comments System */
#comments-container { #comments-container {
padding: 0px 5px 5px 5px; padding: 0px 5px 5px 5px;
background: var(--metadata-bg); background: #00000087;
color: var(--white); color: var(--white);
font-family: var(--font); font-family: var(--font);
} }
@@ -3941,7 +3942,7 @@ input#s_avatar {
.comment-input textarea { .comment-input textarea {
width: 100%; width: 100%;
min-height: 80px; min-height: 80px;
background: var(--bg); background: #000000a1;
border: 1px solid var(--gray); border: 1px solid var(--gray);
color: var(--white); color: var(--white);
padding: 10px; padding: 10px;
@@ -4028,6 +4029,13 @@ input#s_avatar {
text-decoration: underline; text-decoration: underline;
} }
#comment-sort,
#subscribe-btn,
#lock-thread-btn {
background: none;
border: none;
}
/* Admin buttons */ /* Admin buttons */
.admin-edit-btn, .admin-edit-btn,
.admin-delete-btn { .admin-delete-btn {
@@ -4121,7 +4129,7 @@ input#s_avatar {
/* Two-level comment replies */ /* Two-level comment replies */
.comment-replies { .comment-replies {
margin-left: 40px; margin-left: 10px;
border-left: 2px solid var(--gray); border-left: 2px solid var(--gray);
padding-left: 15px; padding-left: 15px;
margin-top: 10px; margin-top: 10px;

View File

@@ -52,7 +52,7 @@
const delbutton = document.createElement("a"); const delbutton = document.createElement("a");
delbutton.innerHTML = " ×"; delbutton.innerHTML = " ×";
delbutton.href = "#"; delbutton.href = "javascript:void(0)";
// Class for delegation // Class for delegation
delbutton.classList.add("admin-deltag"); delbutton.classList.add("admin-deltag");
@@ -70,18 +70,62 @@
if (!ctx) return; if (!ctx) return;
const { postid } = ctx; const { postid } = ctx;
if (!confirm("Do you really want to delete this tag?")) // Use normalized target logic if possible, or assume e.target (wrapped in listener)
return; // In delegation, we passed 'e'. e.target might be text node if not normalized in the handler call?
const tagname = e.target.parentElement.querySelector('a:first-child').innerText; // In listener I normalized 'target', but passed 'e' to deleteEvent.
// So 'e.target' is still the raw event target.
// I should check nodeType here or use closest properly.
let target = e.target;
if (target.nodeType === 3) target = target.parentElement;
const badge = target.closest('.badge');
const tagLink = badge.querySelector('a:first-child');
const tagname = tagLink.innerText;
const modal = document.getElementById('delete-tag-modal');
const nameEl = document.getElementById('delete-tag-name');
const confirmBtn = document.getElementById('delete-tag-confirm');
const cancelBtn = document.getElementById('delete-tag-cancel');
if (modal) {
nameEl.textContent = tagname;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
modal.style.display = 'flex';
const closeModal = () => {
modal.style.display = 'none';
confirmBtn.onclick = null;
cancelBtn.onclick = null;
};
cancelBtn.onclick = closeModal;
confirmBtn.onclick = async () => {
confirmBtn.textContent = 'Deleting...';
confirmBtn.disabled = true;
try {
const res = await (await fetch("/api/v2/admin/" + postid + "/tags/" + encodeURIComponent(tagname), { const res = await (await fetch("/api/v2/admin/" + postid + "/tags/" + encodeURIComponent(tagname), {
method: 'DELETE' method: 'DELETE'
})).json(); })).json();
if (!res.success) if (!res.success) {
return alert("uff"); alert(res.msg || "uff");
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
} else {
renderTags(res.tags); renderTags(res.tags);
closeModal();
}
} catch (err) {
console.error(err);
alert("Error deleting tag");
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
}
};
}
}; };
const addtagClick = (e) => { const addtagClick = (e) => {
@@ -314,30 +358,25 @@
return false; return false;
}; };
// Event Delegation
// Event Delegation // Event Delegation
document.addEventListener("click", e => { document.addEventListener("click", e => {
if (e.target.matches("a#a_addtag")) { const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
if (target.matches("a#a_addtag")) {
addtagClick(e); addtagClick(e);
} else if (e.target.matches("a#a_toggle")) { } else if (target.matches("a#a_toggle")) {
toggleEvent(e); toggleEvent(e);
} else if (e.target.closest("svg#a_favo")) { } else if (target.closest("svg#a_favo")) {
toggleFavEvent(e); toggleFavEvent(e);
} else if (e.target.closest("svg#a_delete")) { } else if (target.closest("svg#a_delete")) {
deleteButtonEvent(e); deleteButtonEvent(e);
} else if (e.target.matches("#tags > .badge > a:first-child")) { } else if (target.matches("#tags > .badge > a:first-child")) {
editTagEvent(e); editTagEvent(e);
} else if (e.target.innerText === " \u00d7" && e.target.closest(".badge")) { // check text " x" or similar for delete? } else if (target.matches("#tags > .badge > a:last-child") || target.closest('.admin-deltag')) {
// Original was " ×" which is × (\u00d7). // Match by structure OR class (added in renderTags)
// Logic in deleteEvent expects match.
// Let's rely on class or structure.
// In renderTags I added class 'admin-deltag'.
// Existing tags in HTML might NOT have this class unless rendered by JS?
// But existing tags are just HTML. We should match structure.
// selector: "#tags > .badge > a:last-child"
if (e.target.matches("#tags > .badge > a:last-child")) {
deleteEvent(e); deleteEvent(e);
} }
}
}); });
document.addEventListener("keyup", e => { document.addEventListener("keyup", e => {

View File

@@ -53,9 +53,8 @@ class CommentSystem {
// If comments are already rendered, we might need to re-render them to show emojis // If comments are already rendered, we might need to re-render them to show emojis
// but usually loadComments also happens async. // but usually loadComments also happens async.
// To be safe, if we just got emojis, trigger a silent update if container exists // To be safe, if we just got emojis, trigger a silent update if container exists
if (this.container && this.container.querySelector('.comment-content')) { if (this.container && this.lastData) {
// This is a bit heavy, but ensures emojis appear if they loaded AFTER comments this.render(this.lastData, this.lastUserId, this.lastIsSubscribed);
// For now let's just let it be.
} }
} else { } else {
this.customEmojis = {}; this.customEmojis = {};
@@ -188,6 +187,11 @@ class CommentSystem {
} }
render(comments, currentUserId, isSubscribed) { render(comments, currentUserId, isSubscribed) {
// Store for re-rendering when emojis load
this.lastData = comments;
this.lastUserId = currentUserId;
this.lastIsSubscribed = isSubscribed;
// Build two-level tree: top-level comments + all replies at one level // Build two-level tree: top-level comments + all replies at one level
const map = new Map(); const map = new Map();
const roots = []; const roots = [];
@@ -695,7 +699,7 @@ class CommentSystem {
closeHandler = null; closeHandler = null;
} }
} else { } else {
picker.style.display = 'block'; picker.style.display = ''; // Reset to CSS default (flex)
closeHandler = (ev) => { closeHandler = (ev) => {
if (!picker.contains(ev.target) && ev.target !== trigger) { if (!picker.contains(ev.target) && ev.target !== trigger) {
picker.style.display = 'none'; picker.style.display = 'none';

View File

@@ -126,6 +126,58 @@ window.requestAnimFrame = (function () {
registerModal.style.display = 'none'; registerModal.style.display = 'none';
} }
}); });
const registerForm = registerModal.querySelector('form');
if (registerForm) {
registerForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(registerForm);
const params = new URLSearchParams(formData);
try {
const res = await fetch('/register', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: params
});
if (res.redirected) {
window.location.href = res.url;
return;
}
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch (e) { }
if (json && json.success === false) {
let errDiv = registerForm.querySelector('.flash-error');
if (!errDiv) {
errDiv = document.createElement('div');
errDiv.className = 'flash-error';
errDiv.style.color = '#ff4444';
errDiv.style.textAlign = 'center';
errDiv.style.marginBottom = '10px';
errDiv.style.fontWeight = 'bold';
registerForm.insertBefore(errDiv, registerForm.firstChild); // Insert before h2 or inputs
// Actually firstChild is text or h2. Let's insert after H2 if possible?
// The form has H2 as first element roughly.
// insertBefore firstChild is fine, it puts it at top.
}
errDiv.textContent = json.msg;
}
} catch (err) {
console.error('Registration error:', err);
}
});
}
} }
// Initialize background preference // Initialize background preference
@@ -295,7 +347,7 @@ window.requestAnimFrame = (function () {
const navbar = document.querySelector('.navbar'); const navbar = document.querySelector('.navbar');
if (navbar) { if (navbar) {
window.addEventListener('scroll', () => { window.addEventListener('scroll', () => {
if (window.scrollY > 50) { if (window.scrollY > 10) {
navbar.classList.add('scrolled'); navbar.classList.add('scrolled');
} else { } else {
navbar.classList.remove('scrolled'); navbar.classList.remove('scrolled');
@@ -466,9 +518,10 @@ window.requestAnimFrame = (function () {
// Intercept clicks // Intercept clicks
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
// Check for thumbnail links on index page // Check for thumbnail links on index page
const thumbnail = e.target.closest('.posts > a'); const thumbnail = target.closest('.posts > a');
if (thumbnail && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { if (thumbnail && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
e.preventDefault(); e.preventDefault();
// Thumbnails inherit context (e.g. from Tag Index) // Thumbnails inherit context (e.g. from Tag Index)
@@ -476,7 +529,7 @@ window.requestAnimFrame = (function () {
return; return;
} }
const link = e.target.closest('#next, #prev, #random, #nav-random, .id-link, .nav-next, .nav-prev'); const link = target.closest('#next, #prev, #random, #nav-random, .id-link, .nav-next, .nav-prev');
if (link && link.href && link.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { if (link && link.href && link.hostname === window.location.hostname && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
// Special check for random // Special check for random
if (link.id === 'random' || link.id === 'nav-random') { if (link.id === 'random' || link.id === 'nav-random') {
@@ -527,7 +580,7 @@ window.requestAnimFrame = (function () {
} else { } else {
loadItemAjax(link.href, true); loadItemAjax(link.href, true);
} }
} else if (e.target.closest('#togglebg')) { } else if (target.closest('#togglebg')) {
e.preventDefault(); e.preventDefault();
background = !background; background = !background;
localStorage.setItem('background', background.toString()); localStorage.setItem('background', background.toString());
@@ -552,9 +605,9 @@ window.requestAnimFrame = (function () {
canvas.classList.add('fader-out'); canvas.classList.add('fader-out');
} }
} }
} else if (e.target.closest('.removetag')) { } else if (target.closest('.removetag')) {
e.preventDefault(); e.preventDefault();
const removeBtn = e.target.closest('.removetag'); const removeBtn = target.closest('.removetag');
const tagLink = removeBtn.previousElementSibling; const tagLink = removeBtn.previousElementSibling;
if (tagLink) { if (tagLink) {
@@ -570,8 +623,11 @@ window.requestAnimFrame = (function () {
if (modal) { if (modal) {
nameEl.textContent = tagName; nameEl.textContent = tagName;
confirmBtn.textContent = 'Delete';
confirmBtn.disabled = false;
modal.style.display = 'flex'; modal.style.display = 'flex';
const closeModal = () => { const closeModal = () => {
modal.style.display = 'none'; modal.style.display = 'none';
confirmBtn.onclick = null; confirmBtn.onclick = null;
@@ -988,7 +1044,7 @@ class NotificationSystem {
init() { init() {
this.bindEvents(); this.bindEvents();
this.poll(); this.poll();
setInterval(() => this.poll(), 60000); // Poll every minute setInterval(() => this.poll(), 10000); // Poll every 10 seconds
} }
bindEvents() { bindEvents() {
@@ -1065,6 +1121,30 @@ class NotificationSystem {
} }
renderItem(n) { renderItem(n) {
if (n.type === 'approve') {
const link = `/${n.item_id}`;
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
<div>
<strong>Your Upload has been approved</strong>
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
</a>
`;
}
if (n.type === 'admin_pending') {
const link = '/admin/approve';
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
<div>
<strong>A new upload needs approval</strong>
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
</a>
`;
}
let typeText = 'Start'; let typeText = 'Start';
if (n.type === 'comment_reply') typeText = 'replied to you'; if (n.type === 'comment_reply') typeText = 'replied to you';
else if (n.type === 'subscription') typeText = 'commented in a thread you follow'; else if (n.type === 'subscription') typeText = 'commented in a thread you follow';

View File

@@ -49,6 +49,17 @@
span.insertAdjacentElement("beforeend", a); span.insertAdjacentElement("beforeend", a);
if (document.querySelector('a[href^="/admin"]')) {
const space = document.createTextNode('\u00A0'); // &nbsp;
span.appendChild(space);
const del = document.createElement("a");
del.className = "removetag";
del.href = "javascript:void(0)";
del.innerHTML = "&#215;";
span.insertAdjacentElement("beforeend", del);
}
document.querySelector("#tags").insertAdjacentElement("afterbegin", span); document.querySelector("#tags").insertAdjacentElement("afterbegin", span);
}); });
}; };
@@ -209,11 +220,12 @@
// Event Delegation // Event Delegation
document.addEventListener("click", e => { document.addEventListener("click", e => {
if (e.target.matches("a#a_addtag")) { const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
if (target.matches("a#a_addtag")) {
addtagClick(e); addtagClick(e);
} else if (e.target.matches("a#a_toggle")) { } else if (target.matches("a#a_toggle")) {
toggleEvent(e); toggleEvent(e);
} else if (e.target.closest("svg#a_favo")) { } else if (target.closest("svg#a_favo")) {
toggleFavEvent(e); toggleFavEvent(e);
} }
}); });

View File

@@ -47,7 +47,7 @@ export default new class {
tmp = "items.id in (select item_id from tags_assign where tag_id = 2 group by item_id)"; tmp = "items.id in (select item_id from tags_assign where tag_id = 2 group by item_id)";
break; break;
case 2: // untagged case 2: // untagged
tmp = "items.id not in (select item_id from tags_assign group by item_id)"; tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
break; break;
case 3: // all case 3: // all
tmp = "1 = 1"; tmp = "1 = 1";
@@ -94,7 +94,7 @@ export default new class {
const untagged = +(await db` const untagged = +(await db`
select count(*) as total select count(*) as total
from "items" from "items"
where id not in (select item_id from tags_assign group by item_id) and active = true where not exists (select 1 from tags_assign where item_id = items.id) and active = true
`)[0].total; `)[0].total;
const sfw = +(await db` const sfw = +(await db`
select count(*) as total select count(*) as total

View File

@@ -33,7 +33,7 @@ export default {
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``} ${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``} ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${mime ? db`and items.mime ilike ${smime}` : db``} ${mime ? db`and items.mime ilike ${smime}` : db``}
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!o.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag group by items.id, tags.tag
`)?.length || 0; `)?.length || 0;
@@ -67,7 +67,7 @@ export default {
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``} ${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``} ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${mime ? db`and items.mime ilike ${smime}` : db``} ${mime ? db`and items.mime ilike ${smime}` : db``}
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!o.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag, ta.tag_id group by items.id, tags.tag, ta.tag_id
order by items.id desc order by items.id desc
offset ${offset} offset ${offset}
@@ -131,7 +131,7 @@ export default {
${o.fav ? db`and "user"."user" ilike ${user}` : db``} ${o.fav ? db`and "user"."user" ilike ${user}` : db``}
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``} ${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${mime ? db`and items.mime ilike ${smime}` : db``} ${mime ? db`and items.mime ilike ${smime}` : db``}
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!o.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag, ta.tag_id group by items.id, tags.tag, ta.tag_id
order by items.id desc order by items.id desc
`; `;
@@ -234,7 +234,7 @@ export default {
and "user".user ilike ${'%' + user + '%'} and "user".user ilike ${'%' + user + '%'}
and items.active = 'true' and items.active = 'true'
${mime ? db`and items.mime ilike ${smime}` : db``} ${mime ? db`and items.mime ilike ${smime}` : db``}
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!o.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id group by items.id
order by random() order by random()
limit 1 limit 1
@@ -253,7 +253,7 @@ export default {
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``} ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${user ? db`and items.username ilike ${'%' + user + '%'}` : db``} ${user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
${mime ? db`and items.mime ilike ${smime}` : db``} ${mime ? db`and items.mime ilike ${smime}` : db``}
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!o.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
group by items.id, tags.tag group by items.id, tags.tag
order by random() order by random()
limit 1 limit 1

View File

@@ -125,7 +125,7 @@ export default (router, tpl) => {
if (req.url.qs?.id) { if (req.url.qs?.id) {
const id = +req.url.qs.id; const id = +req.url.qs.id;
const f0ck = await db` const f0ck = await db`
select dest, mime select dest, mime, username, id
from "items" from "items"
where where
id = ${id} and id = ${id} and
@@ -138,6 +138,19 @@ export default (router, tpl) => {
}); });
} }
// Notify User
try {
const uploader = await db`select id from "user" where login = ${f0ck[0].username} limit 1`;
if (uploader.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, item_id)
VALUES (${uploader[0].id}, 'approve', 0, ${id})
`;
}
} catch (err) {
console.error('[ADMIN APPROVE] Failed to notify user:', err);
}
await db`update "items" set active = 'true', is_deleted = false where id = ${id}`; await db`update "items" set active = 'true', is_deleted = false where id = ${id}`;
// Check if files need moving (if they are in deleted/) // Check if files need moving (if they are in deleted/)

View File

@@ -40,7 +40,7 @@ export default router => {
active = 'true' active = 'true'
${isFav ? db`and fu."user" = ${user}` : db`and items.username ilike ${user}`} ${isFav ? db`and fu."user" = ${user}` : db`and items.username ilike ${user}`}
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``} ${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
${!hasSession && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``} ${!hasSession && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
order by random() order by random()
limit 1 limit 1
`; `;

View File

@@ -58,6 +58,87 @@ export default (router, tpl) => {
} }
}); });
// Browse User Comments
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
const user = decodeURIComponent(req.params.user);
try {
// Check if user exists and get ID + avatar
const u = await db`
SELECT "user".id, "user".user, user_options.avatar
FROM "user"
LEFT JOIN user_options ON "user".id = user_options.user_id
WHERE "user".user ILIKE ${user}
`;
if (!u.length) {
return res.reply({ code: 404, body: "User not found" });
}
const userId = u[0].id;
const sort = req.url.qs?.sort || 'new';
const page = +(req.url.qs?.page || 1);
const limit = 20;
const offset = (page - 1) * limit;
const isJson = req.url.qs?.json === 'true';
const comments = await db`
SELECT c.*, i.mime, i.id as item_id
FROM comments c
LEFT JOIN items i ON c.item_id = i.id
WHERE c.user_id = ${userId} AND c.is_deleted = false
ORDER BY c.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
const emojis = await db`SELECT name, url FROM custom_emojis`;
const emojiMap = new Map();
emojis.forEach(e => emojiMap.set(e.name, e.url));
const escapeHtml = (unsafe) => {
return (unsafe || '')
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
};
const processedComments = comments.map(c => {
let safeContent = escapeHtml(c.content);
// Replace :emoji: with img
safeContent = safeContent.replace(/:([a-z0-9_]+):/g, (match, name) => {
if (emojiMap.has(name)) {
return `<img src="${emojiMap.get(name)}" style="height:20px;vertical-align:middle;" alt="${name}">`;
}
return match;
});
return {
...c,
content: safeContent
};
});
if (isJson) {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, comments: processedComments })
});
}
const data = {
user: u[0],
comments: processedComments,
hidePagination: true,
tmp: null // for header/footer
};
return res.reply({ body: tpl.render('comments_user', data, req) });
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: "Error" });
}
});
// Post a comment // Post a comment
router.post('/api/comments', async (req, res) => { router.post('/api/comments', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) }); if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });

View File

@@ -69,6 +69,13 @@ export default (router, tpl) => {
count.favs = 0; count.favs = 0;
} }
try {
const comms = await db`select count(*) from comments where user_id = ${query[0].user_id}`;
count.comments = +comms[0].count;
} catch (e) {
count.comments = 0;
}
const data = { const data = {
user: query[0], user: query[0],
f0cks, f0cks,

View File

@@ -9,10 +9,11 @@ export default (router, tpl) => {
try { try {
const notifications = await db` const notifications = await db`
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read,
u.user as from_user, u.id as from_user_id COALESCE(u.user, 'System') as from_user,
COALESCE(u.id, 0) as from_user_id
FROM notifications n FROM notifications n
JOIN comments c ON n.reference_id = c.id LEFT JOIN comments c ON n.reference_id = c.id
JOIN "user" u ON c.user_id = u.id LEFT JOIN "user" u ON c.user_id = u.id
WHERE n.user_id = ${req.session.id} AND n.is_read = false WHERE n.user_id = ${req.session.id} AND n.is_read = false
ORDER BY n.created_at DESC ORDER BY n.created_at DESC
LIMIT 20 LIMIT 20

View File

@@ -15,6 +15,9 @@ export default (router, tpl) => {
const { username, password, password_confirm, token } = req.post; const { username, password, password_confirm, token } = req.post;
const renderError = (msg) => { const renderError = (msg) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
return res.reply({ return res.reply({
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck", error: msg }) body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck", error: msg })
}); });

View File

@@ -241,6 +241,19 @@ export const handleUpload = async (req, res) => {
`; `;
} }
// Notify Admins
try {
const admins = await db`select id from "user" where admin = true`;
for (const admin of admins) {
await db`
INSERT INTO notifications (user_id, type, reference_id, item_id)
VALUES (${admin.id}, 'admin_pending', 0, ${itemid})
`;
}
} catch (err) {
console.error('[UPLOAD HANDLER] Failed to notify admins:', err);
}
return sendJson(res, { return sendJson(res, {
success: true, success: true,
msg: 'Upload successful! Your upload is pending admin approval.', msg: 'Upload successful! Your upload is pending admin approval.',

108
views/comments_user.html Normal file
View File

@@ -0,0 +1,108 @@
@include(snippets/header)
<div id="main">
<div class="profile_head">
@if(user.avatar)
<div class="profile_head_avatar">
<img src="/t/{{ user.avatar }}.webp" style="display: grid;width: 55px" />
</div>
@endif
<div class="layersoffear">
<div class="profile_head_username">
<span>{{ user.user }}'s Comments</span>
</div>
<div class="profile_head_user_stats">
<a href="/user/{{ user.user }}">Back to Profile</a>
</div>
</div>
</div>
<div class="user_content_wrapper" style="display: block;">
<div class="comments-list-page" style="max-width: 800px; margin: 0 auto;">
@each(comments as c)
<div class="user-comment-row"
style="display: flex; gap: 10px; margin-bottom: 10px; background: rgba(255,255,255,0.05); padding: 10px; border-radius: 4px;">
<div class="comment-thumbnail" style="flex-shrink: 0;">
<a href="/{{ c.item_id }}#c{{ c.id }}">
<img src="/t/{{ c.item_id }}.webp"
style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px;">
</a>
</div>
<div class="comment-preview" style="flex-grow: 1;">
<div style="font-size: 0.8em; color: #888; margin-bottom: 5px;">
On <a href="/{{ c.item_id }}">Item #{{ c.item_id }}</a> - {{ c.created_at }}
</div>
<div class="comment-text">
{{ c.content }}
</div>
</div>
</div>
@endeach
</div>
</div>
</div>
@include(snippets/footer)
<script>
let page = 1;
let loading = false;
let finished = false;
const user = "{{ user.user }}";
const container = document.querySelector('.comments-list-page');
window.addEventListener('scroll', () => {
if (loading || finished) return;
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
loadMore();
}
});
async function loadMore() {
loading = true;
page++;
// Show loading indicator?
const loader = document.createElement('div');
loader.className = 'loader-placeholder';
loader.innerText = 'Loading...';
loader.style.textAlign = 'center';
loader.style.padding = '10px';
container.appendChild(loader);
try {
const res = await fetch('/user/' + encodeURIComponent(user) + '/comments?page=' + page + '&json=true');
const json = await res.json();
loader.remove();
if (json.success && json.comments.length > 0) {
json.comments.forEach(c => {
const div = document.createElement('div');
div.className = 'user-comment-row';
div.style.cssText = 'display: flex; gap: 10px; margin-bottom: 10px; background: rgba(255,255,255,0.05); padding: 10px; border-radius: 4px;';
let html = '<div class="comment-thumbnail" style="flex-shrink: 0;">';
html += '<a href="/' + c.item_id + '#c' + c.id + '">';
html += '<img src="/t/' + c.item_id + '.webp" style="width: 80px; height: 80px; object-fit: cover; border-radius: 4px;">';
html += '</a></div>';
html += '<div class="comment-preview" style="flex-grow: 1;">';
html += '<div style="font-size: 0.8em; color: #888; margin-bottom: 5px;">';
html += 'On <a href="/' + c.item_id + '">Item #' + c.item_id + '</a> - ' + new Date(c.created_at).toLocaleString();
html += '</div>';
html += '<div class="comment-text">' + c.content + '</div>';
html += '</div>';
div.innerHTML = html;
container.appendChild(div);
});
} else {
finished = true;
}
} catch (e) {
console.error(e);
loader.remove();
} finally {
loading = false;
}
}
</script>

View File

@@ -2,8 +2,8 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
<div class="index-container"> <div class="index-container">
@if(tmp.user)<h2>user: <a href="/user/{{ tmp.user.toLowerCase() }}">{{ tmp.user.toLowerCase() }}</a>@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif @if(tmp.user)<h2>user: <a href="/user/{{ tmp.user.toLowerCase() }}">{!! tmp.user.toLowerCase() !!}</a>@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif
@if(tmp.tag)<h2>tag: @if(session)<a href="/search?tag={{ tmp.tag.toLowerCase() }}" target="_blank">{{ tmp.tag.toLowerCase() }}</a>@else{{ tmp.tag.toLowerCase() }}@endif@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif @if(tmp.tag)<h2>tag: @if(session)<a href="/search?tag={!! tmp.tag.toLowerCase() !!}" target="_blank">{!! tmp.tag.toLowerCase() !!}</a>@else{!! tmp.tag.toLowerCase() !!}@endif@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif
<div class="posts"> <div class="posts">
@each(items as item) @each(items as item)
<a href="{{ link.main }}{{ item.id }}" data-mime="{{ item.mime }}" data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}" style="background-image: url('/t/{{ item.id }}.webp')"><p></p></a> <a href="{{ link.main }}{{ item.id }}" data-mime="{{ item.mime }}" data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}" style="background-image: url('/t/{{ item.id }}.webp')"><p></p></a>

View File

@@ -105,7 +105,7 @@
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)
<span @if(session)tooltip="{{ tag.user }}" @endif class="badge {{ tag.badge }} mr-2"> <span @if(session)tooltip="{{ tag.user }}" @endif class="badge {{ tag.badge }} mr-2">
<a href="/tag/{{ tag.normalized }}">{{ tag.tag }}</a>@if(session.admin)&nbsp;<a class="removetag" <a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(session.admin)&nbsp;<a class="removetag"
href="#">&#215;</a>@endif href="#">&#215;</a>@endif
</span> </span>
@endeach @endeach

View File

@@ -24,7 +24,7 @@
</tr> </tr>
<tr> <tr>
<td>username</td> <td>username</td>
<td>{{ session.user }}</td> <td>{!! session.user !!}</td>
</tr> </tr>
<tr> <tr>
<td>@if(session.avatar)<a href="/{{ session.avatar }}"><img id="img_avatar" <td>@if(session.avatar)<a href="/{{ session.avatar }}"><img id="img_avatar"

View File

@@ -23,7 +23,11 @@
@if(count.f0cks) @if(count.f0cks)
<div class="posts"> <div class="posts">
@each(f0cks.items as item) @each(f0cks.items as item)
<a href="{{ f0cks.link.main }}{{ item.id }}" data-mime="{{ item.mime }}" data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}" style="background-image: url('/t/{{ item.id }}.webp')"><p></p></a> <a href="{{ f0cks.link.main }}{{ item.id }}" data-mime="{{ item.mime }}"
data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}"
style="background-image: url('/t/{{ item.id }}.webp')">
<p></p>
</a>
@endeach @endeach
</div> </div>
@else @else
@@ -37,13 +41,23 @@
@if(count.favs) @if(count.favs)
<div class="posts"> <div class="posts">
@each(favs.items as item) @each(favs.items as item)
<a href="{{ favs.link.main }}{{ item.id }}" data-mime="{{ item.mime }}" data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}" style="background-image: url('/t/{{ item.id }}.webp')"><p></p></a> <a href="{{ favs.link.main }}{{ item.id }}" data-mime="{{ item.mime }}"
data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}"
style="background-image: url('/t/{{ item.id }}.webp')">
<p></p>
</a>
@endeach @endeach
</div> </div>
@else @else
no favorites no favorites
@endif @endif
</div> </div>
<div class="comments-section">
<div class="comments-header">
comment{{ count.comments == 1 ? '' : 's' }}: {{ count.comments }} <a href="/user/{{ user.user }}/comments">view
all</a>
</div>
</div>
</div> </div>
</div> </div>
@include(snippets/footer) @include(snippets/footer)