Compare commits
28 Commits
comments-f
...
f0bm
| Author | SHA1 | Date | |
|---|---|---|---|
| 04f4166dbe | |||
| f03b938fd0 | |||
| 966b04dbaf | |||
| 8cdf4a12b8 | |||
| dfc8ebba89 | |||
| 002dfeded3 | |||
| ca86cf7c6e | |||
| 734071beab | |||
| 0e9fedc9b4 | |||
| 8fe362c966 | |||
| 8180cdd885 | |||
| b71711aa96 | |||
| 588ab3c3de | |||
| 46fb7067ed | |||
| 5815334e7b | |||
| 6bfb200626 | |||
| 166ba96f0f | |||
| a2ebea2ba3 | |||
| 91546a1f0d | |||
| 017ac4ca4c | |||
| cb31cfd85b | |||
| a5acc22c4a | |||
| dbcf39c3ba | |||
| 4d2fd7561f | |||
| c4bb21bc31 | |||
| 7d58acd5ed | |||
| 286791de0d | |||
| 1b8a9185bb |
1
add_index_tags_assign.sql
Normal file
1
add_index_tags_assign.sql
Normal file
@@ -0,0 +1 @@
|
||||
CREATE INDEX CONCURRENTLY IF NOT EXISTS tags_assign_tag_id_idx ON public.tags_assign (tag_id);
|
||||
@@ -1596,7 +1596,8 @@ div.posts>a:hover::after {
|
||||
}
|
||||
|
||||
.navbar.scrolled {
|
||||
background: #000 !important;
|
||||
background: black !important;
|
||||
transition: background 0.1s ease;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@@ -3848,7 +3849,7 @@ input#s_avatar {
|
||||
/* Comments System */
|
||||
#comments-container {
|
||||
padding: 0px 5px 5px 5px;
|
||||
background: var(--metadata-bg);
|
||||
background: #00000087;
|
||||
color: var(--white);
|
||||
font-family: var(--font);
|
||||
}
|
||||
@@ -3873,9 +3874,9 @@ input#s_avatar {
|
||||
.comment {
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.comment.deleted .comment-content {
|
||||
@@ -3886,7 +3887,6 @@ input#s_avatar {
|
||||
.comment-avatar img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
@@ -3941,7 +3941,7 @@ input#s_avatar {
|
||||
.comment-input textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
background: var(--bg);
|
||||
background: #000000a1;
|
||||
border: 1px solid var(--gray);
|
||||
color: var(--white);
|
||||
padding: 10px;
|
||||
@@ -4028,6 +4028,13 @@ input#s_avatar {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#comment-sort,
|
||||
#subscribe-btn,
|
||||
#lock-thread-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Admin buttons */
|
||||
.admin-edit-btn,
|
||||
.admin-delete-btn {
|
||||
@@ -4121,7 +4128,7 @@ input#s_avatar {
|
||||
|
||||
/* Two-level comment replies */
|
||||
.comment-replies {
|
||||
margin-left: 40px;
|
||||
margin-left: 10px;
|
||||
border-left: 2px solid var(--gray);
|
||||
padding-left: 15px;
|
||||
margin-top: 10px;
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.innerHTML = " ×";
|
||||
delbutton.href = "#";
|
||||
delbutton.href = "javascript:void(0)";
|
||||
// Class for delegation
|
||||
delbutton.classList.add("admin-deltag");
|
||||
|
||||
@@ -70,18 +70,62 @@
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
if (!confirm("Do you really want to delete this tag?"))
|
||||
return;
|
||||
const tagname = e.target.parentElement.querySelector('a:first-child').innerText;
|
||||
// Use normalized target logic if possible, or assume e.target (wrapped in listener)
|
||||
// In delegation, we passed 'e'. e.target might be text node if not normalized in the handler call?
|
||||
// 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), {
|
||||
method: 'DELETE'
|
||||
})).json();
|
||||
|
||||
if (!res.success)
|
||||
return alert("uff");
|
||||
|
||||
if (!res.success) {
|
||||
alert(res.msg || "uff");
|
||||
confirmBtn.textContent = 'Delete';
|
||||
confirmBtn.disabled = false;
|
||||
} else {
|
||||
renderTags(res.tags);
|
||||
closeModal();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert("Error deleting tag");
|
||||
confirmBtn.textContent = 'Delete';
|
||||
confirmBtn.disabled = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const addtagClick = (e) => {
|
||||
@@ -314,30 +358,25 @@
|
||||
return false;
|
||||
};
|
||||
|
||||
// Event Delegation
|
||||
// Event Delegation
|
||||
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);
|
||||
} else if (e.target.matches("a#a_toggle")) {
|
||||
} else if (target.matches("a#a_toggle")) {
|
||||
toggleEvent(e);
|
||||
} else if (e.target.closest("svg#a_favo")) {
|
||||
} else if (target.closest("svg#a_favo")) {
|
||||
toggleFavEvent(e);
|
||||
} else if (e.target.closest("svg#a_delete")) {
|
||||
} else if (target.closest("svg#a_delete")) {
|
||||
deleteButtonEvent(e);
|
||||
} else if (e.target.matches("#tags > .badge > a:first-child")) {
|
||||
} else if (target.matches("#tags > .badge > a:first-child")) {
|
||||
editTagEvent(e);
|
||||
} else if (e.target.innerText === " \u00d7" && e.target.closest(".badge")) { // check text " x" or similar for delete?
|
||||
// Original was " ×" which is × (\u00d7).
|
||||
// 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")) {
|
||||
} else if (target.matches("#tags > .badge > a:last-child") || target.closest('.admin-deltag')) {
|
||||
// Match by structure OR class (added in renderTags)
|
||||
deleteEvent(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("keyup", e => {
|
||||
|
||||
@@ -53,9 +53,8 @@ class CommentSystem {
|
||||
// If comments are already rendered, we might need to re-render them to show emojis
|
||||
// but usually loadComments also happens async.
|
||||
// To be safe, if we just got emojis, trigger a silent update if container exists
|
||||
if (this.container && this.container.querySelector('.comment-content')) {
|
||||
// This is a bit heavy, but ensures emojis appear if they loaded AFTER comments
|
||||
// For now let's just let it be.
|
||||
if (this.container && this.lastData) {
|
||||
this.render(this.lastData, this.lastUserId, this.lastIsSubscribed);
|
||||
}
|
||||
} else {
|
||||
this.customEmojis = {};
|
||||
@@ -188,6 +187,11 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
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
|
||||
const map = new Map();
|
||||
const roots = [];
|
||||
@@ -695,7 +699,7 @@ class CommentSystem {
|
||||
closeHandler = null;
|
||||
}
|
||||
} else {
|
||||
picker.style.display = 'block';
|
||||
picker.style.display = ''; // Reset to CSS default (flex)
|
||||
closeHandler = (ev) => {
|
||||
if (!picker.contains(ev.target) && ev.target !== trigger) {
|
||||
picker.style.display = 'none';
|
||||
|
||||
@@ -126,6 +126,58 @@ window.requestAnimFrame = (function () {
|
||||
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
|
||||
@@ -295,7 +347,7 @@ window.requestAnimFrame = (function () {
|
||||
const navbar = document.querySelector('.navbar');
|
||||
if (navbar) {
|
||||
window.addEventListener('scroll', () => {
|
||||
if (window.scrollY > 50) {
|
||||
if (window.scrollY > 10) {
|
||||
navbar.classList.add('scrolled');
|
||||
} else {
|
||||
navbar.classList.remove('scrolled');
|
||||
@@ -466,9 +518,10 @@ window.requestAnimFrame = (function () {
|
||||
|
||||
// Intercept clicks
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target.nodeType === 3 ? e.target.parentElement : e.target;
|
||||
|
||||
// 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) {
|
||||
e.preventDefault();
|
||||
// Thumbnails inherit context (e.g. from Tag Index)
|
||||
@@ -476,7 +529,7 @@ window.requestAnimFrame = (function () {
|
||||
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) {
|
||||
// Special check for random
|
||||
if (link.id === 'random' || link.id === 'nav-random') {
|
||||
@@ -527,7 +580,7 @@ window.requestAnimFrame = (function () {
|
||||
} else {
|
||||
loadItemAjax(link.href, true);
|
||||
}
|
||||
} else if (e.target.closest('#togglebg')) {
|
||||
} else if (target.closest('#togglebg')) {
|
||||
e.preventDefault();
|
||||
background = !background;
|
||||
localStorage.setItem('background', background.toString());
|
||||
@@ -552,9 +605,9 @@ window.requestAnimFrame = (function () {
|
||||
canvas.classList.add('fader-out');
|
||||
}
|
||||
}
|
||||
} else if (e.target.closest('.removetag')) {
|
||||
} else if (target.closest('.removetag')) {
|
||||
e.preventDefault();
|
||||
const removeBtn = e.target.closest('.removetag');
|
||||
const removeBtn = target.closest('.removetag');
|
||||
const tagLink = removeBtn.previousElementSibling;
|
||||
|
||||
if (tagLink) {
|
||||
@@ -570,8 +623,11 @@ window.requestAnimFrame = (function () {
|
||||
|
||||
if (modal) {
|
||||
nameEl.textContent = tagName;
|
||||
confirmBtn.textContent = 'Delete';
|
||||
confirmBtn.disabled = false;
|
||||
modal.style.display = 'flex';
|
||||
|
||||
|
||||
const closeModal = () => {
|
||||
modal.style.display = 'none';
|
||||
confirmBtn.onclick = null;
|
||||
@@ -988,7 +1044,7 @@ class NotificationSystem {
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.poll();
|
||||
setInterval(() => this.poll(), 60000); // Poll every minute
|
||||
setInterval(() => this.poll(), 10000); // Poll every 10 seconds
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
@@ -1065,6 +1121,30 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
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';
|
||||
if (n.type === 'comment_reply') typeText = 'replied to you';
|
||||
else if (n.type === 'subscription') typeText = 'commented in a thread you follow';
|
||||
|
||||
@@ -49,6 +49,17 @@
|
||||
|
||||
span.insertAdjacentElement("beforeend", a);
|
||||
|
||||
if (document.querySelector('a[href^="/admin"]')) {
|
||||
const space = document.createTextNode('\u00A0'); //
|
||||
span.appendChild(space);
|
||||
|
||||
const del = document.createElement("a");
|
||||
del.className = "removetag";
|
||||
del.href = "javascript:void(0)";
|
||||
del.innerHTML = "×";
|
||||
span.insertAdjacentElement("beforeend", del);
|
||||
}
|
||||
|
||||
document.querySelector("#tags").insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
};
|
||||
@@ -209,11 +220,12 @@
|
||||
|
||||
// Event Delegation
|
||||
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);
|
||||
} else if (e.target.matches("a#a_toggle")) {
|
||||
} else if (target.matches("a#a_toggle")) {
|
||||
toggleEvent(e);
|
||||
} else if (e.target.closest("svg#a_favo")) {
|
||||
} else if (target.closest("svg#a_favo")) {
|
||||
toggleFavEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
190
public/s/js/user_comments.js
Normal file
190
public/s/js/user_comments.js
Normal file
@@ -0,0 +1,190 @@
|
||||
class UserCommentSystem {
|
||||
constructor() {
|
||||
this.container = document.getElementById('user-comments-container');
|
||||
this.username = this.container ? this.container.dataset.user : null;
|
||||
this.page = 1;
|
||||
this.loading = false;
|
||||
this.finished = false;
|
||||
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||
|
||||
if (this.username) {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.loadEmojis();
|
||||
this.loadMore();
|
||||
this.bindEvents();
|
||||
}
|
||||
|
||||
async loadEmojis() {
|
||||
if (UserCommentSystem.emojiCache) {
|
||||
this.customEmojis = UserCommentSystem.emojiCache;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/v2/emojis');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
this.customEmojis = {};
|
||||
data.emojis.forEach(e => {
|
||||
this.customEmojis[e.name] = e.url;
|
||||
});
|
||||
UserCommentSystem.emojiCache = this.customEmojis;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load emojis", e);
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
window.addEventListener('scroll', () => {
|
||||
if (this.loading || this.finished) return;
|
||||
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight - 500) {
|
||||
this.loadMore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadMore() {
|
||||
if (this.loading || this.finished) return;
|
||||
this.loading = true;
|
||||
|
||||
const loader = document.createElement('div');
|
||||
loader.className = 'loader-placeholder';
|
||||
loader.innerText = 'Loading...';
|
||||
loader.style.textAlign = 'center';
|
||||
loader.style.padding = '10px';
|
||||
this.container.appendChild(loader);
|
||||
|
||||
try {
|
||||
const res = await fetch('/user/' + encodeURIComponent(this.username) + '/comments?page=' + this.page + '&json=true');
|
||||
const json = await res.json();
|
||||
|
||||
loader.remove();
|
||||
|
||||
if (json.success && json.comments.length > 0) {
|
||||
json.comments.forEach(c => {
|
||||
console.log('Raw Comment Content (ID ' + c.id + '):', c.content);
|
||||
const html = this.renderComment(c);
|
||||
this.container.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
this.page++;
|
||||
} else {
|
||||
this.finished = true;
|
||||
if (this.page === 1 && (!json.comments || json.comments.length === 0)) {
|
||||
this.container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No comments found.</div>';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
loader.remove();
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
renderEmoji(match, name) {
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
renderCommentContent(content) {
|
||||
if (typeof marked === 'undefined') {
|
||||
console.error('UserCommentSystem: marked.js is undefined!');
|
||||
return this.escapeHtml(content).replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Pre-process server-escaped content
|
||||
|
||||
// Fix Greentext: Server sends >lool -> convert to > lool for marked
|
||||
let safe = content.replace(/^>/gm, '> ');
|
||||
// Also handle raw > just in case
|
||||
safe = safe.replace(/^>(?=[^ ])/gm, '> ');
|
||||
|
||||
// Fix Images: Server sends <img src="..." ...> -> convert to markdown ![]() or :emoji:
|
||||
// This avoids them being escaped by our HTML escaping later
|
||||
safe = safe.replace(/<img\s+[^>]*src="([^"]+)"[^>]*>/g, (match, src) => {
|
||||
let altMatch = match.match(/alt="([^"]*)"/);
|
||||
let alt = altMatch ? altMatch[1] : '';
|
||||
|
||||
// Check if it's a known emoji (convert back to :code: for consistent rendering)
|
||||
// We check if the name exists in our map. Validating src is good but name check is usually enough here.
|
||||
if (alt && this.customEmojis && this.customEmojis[alt]) {
|
||||
return `:${alt}:`;
|
||||
}
|
||||
|
||||
return ``;
|
||||
});
|
||||
|
||||
// 2. Escape HTML (standard safety)
|
||||
safe = safe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const renderer = new marked.Renderer();
|
||||
renderer.blockquote = function (quote) {
|
||||
let text = (typeof quote === 'string') ? quote : (quote.text || '');
|
||||
let cleanQuote = text.replace(/<p>|<\/p>|\n/g, '');
|
||||
return `<span class="greentext">> ${cleanQuote}</span><br>`;
|
||||
};
|
||||
|
||||
// 3. Parse Markdown
|
||||
let md = marked.parse(safe, {
|
||||
breaks: true,
|
||||
renderer: renderer
|
||||
});
|
||||
|
||||
return md.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||
} catch (e) {
|
||||
console.error('UserCommentSystem Markdown Render Error:', e);
|
||||
return this.escapeHtml(content);
|
||||
}
|
||||
}
|
||||
|
||||
renderComment(c) {
|
||||
const date = new Date(c.created_at).toLocaleString();
|
||||
const content = this.renderCommentContent(c.content);
|
||||
|
||||
// Replicating the structure of comments.js but adapting for the list view
|
||||
// We add a header indicating which item this comment belongs to
|
||||
|
||||
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="Item Thumbnail">
|
||||
</a>
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-meta">
|
||||
<span class="comment-author">${this.username}</span>
|
||||
<span class="comment-time">${date}</span>
|
||||
<a href="/${c.item_id}#c${c.id}" class="comment-permalink">#${c.id}</a>
|
||||
<span class="comment-item-ref">on <a href="/${c.item_id}">Item #${c.item_id}</a></span>
|
||||
</div>
|
||||
<div class="comment-content">${content}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
new UserCommentSystem();
|
||||
});
|
||||
@@ -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)";
|
||||
break;
|
||||
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;
|
||||
case 3: // all
|
||||
tmp = "1 = 1";
|
||||
@@ -94,7 +94,7 @@ export default new class {
|
||||
const untagged = +(await db`
|
||||
select count(*) as total
|
||||
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;
|
||||
const sfw = +(await db`
|
||||
select count(*) as total
|
||||
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
|
||||
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : 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
|
||||
`)?.length || 0;
|
||||
|
||||
@@ -67,7 +67,7 @@ export default {
|
||||
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
|
||||
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : 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
|
||||
order by items.id desc
|
||||
offset ${offset}
|
||||
@@ -131,7 +131,7 @@ export default {
|
||||
${o.fav ? db`and "user"."user" ilike ${user}` : db``}
|
||||
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : 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
|
||||
order by items.id desc
|
||||
`;
|
||||
@@ -234,7 +234,7 @@ export default {
|
||||
and "user".user ilike ${'%' + user + '%'}
|
||||
and items.active = 'true'
|
||||
${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
|
||||
order by random()
|
||||
limit 1
|
||||
@@ -253,7 +253,7 @@ export default {
|
||||
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||
${user ? db`and items.username ilike ${'%' + user + '%'}` : 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
|
||||
order by random()
|
||||
limit 1
|
||||
|
||||
@@ -125,7 +125,7 @@ export default (router, tpl) => {
|
||||
if (req.url.qs?.id) {
|
||||
const id = +req.url.qs.id;
|
||||
const f0ck = await db`
|
||||
select dest, mime
|
||||
select dest, mime, username, id
|
||||
from "items"
|
||||
where
|
||||
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}`;
|
||||
|
||||
// Check if files need moving (if they are in deleted/)
|
||||
|
||||
@@ -40,7 +40,7 @@ export default router => {
|
||||
active = 'true'
|
||||
${isFav ? db`and fu."user" = ${user}` : db`and items.username ilike ${user}`}
|
||||
${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()
|
||||
limit 1
|
||||
`;
|
||||
|
||||
@@ -58,6 +58,99 @@ 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';
|
||||
|
||||
// Require login
|
||||
if (!req.session || !req.session.user) {
|
||||
if (isJson) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, require_login: true })
|
||||
});
|
||||
} else {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
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
|
||||
router.post('/api/comments', async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
|
||||
|
||||
@@ -69,6 +69,13 @@ export default (router, tpl) => {
|
||||
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 = {
|
||||
user: query[0],
|
||||
f0cks,
|
||||
|
||||
@@ -9,10 +9,11 @@ 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,
|
||||
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
|
||||
JOIN comments c ON n.reference_id = c.id
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN comments c ON n.reference_id = c.id
|
||||
LEFT JOIN "user" u ON c.user_id = u.id
|
||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 20
|
||||
|
||||
@@ -15,6 +15,9 @@ export default (router, tpl) => {
|
||||
const { username, password, password_confirm, token } = req.post;
|
||||
|
||||
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({
|
||||
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck", error: msg })
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
success: true,
|
||||
msg: 'Upload successful! Your upload is pending admin approval.',
|
||||
|
||||
30
views/comments_user.html
Normal file
30
views/comments_user.html
Normal file
@@ -0,0 +1,30 @@
|
||||
@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;">
|
||||
<!-- Container for CSR comments -->
|
||||
<div id="user-comments-container" data-user="{{ user.user }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include(snippets/footer)
|
||||
<!-- Include local script for this page -->
|
||||
<script src="/s/js/user_comments.js?v=1"></script>
|
||||
@@ -2,8 +2,8 @@
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
<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.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.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
|
||||
<div class="posts">
|
||||
@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>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
@if(typeof item.tags !== "undefined")
|
||||
@each(item.tags as tag)
|
||||
<span @if(session)tooltip="{{ tag.user }}" @endif class="badge {{ tag.badge }} mr-2">
|
||||
<a href="/tag/{{ tag.normalized }}">{{ tag.tag }}</a>@if(session.admin) <a class="removetag"
|
||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(session.admin) <a class="removetag"
|
||||
href="#">×</a>@endif
|
||||
</span>
|
||||
@endeach
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>username</td>
|
||||
<td>{{ session.user }}</td>
|
||||
<td>{!! session.user !!}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>@if(session.avatar)<a href="/{{ session.avatar }}"><img id="img_avatar"
|
||||
|
||||
@@ -23,7 +23,11 @@
|
||||
@if(count.f0cks)
|
||||
<div class="posts">
|
||||
@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
|
||||
</div>
|
||||
@else
|
||||
@@ -37,13 +41,23 @@
|
||||
@if(count.favs)
|
||||
<div class="posts">
|
||||
@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
|
||||
</div>
|
||||
@else
|
||||
no favorites
|
||||
@endif
|
||||
</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>
|
||||
@include(snippets/footer)
|
||||
Reference in New Issue
Block a user