feat: Implement comments, notifications, and custom emojis with new API routes, UI components, and database migrations.

This commit is contained in:
x
2026-01-25 03:48:24 +01:00
parent 595118c2c8
commit d903ce8b98
18 changed files with 1900 additions and 44 deletions

View File

@@ -788,18 +788,277 @@ html[theme="f0ck95"] #next {
}
/* removing in favor of new appearance */
/* html[theme="f0ck95"] .navbar-brand:hover {
background: #80808059;
} */
/*
html[theme="f0ck95"] span.f0ck::after {
content: "95";
font-size: 14px;
font-family: vcr;
vertical-align: super;
color: teal;
/* Notifications */
.nav-item-rel {
position: relative;
display: inline-block;
}
.notif-count {
position: absolute;
top: 0px;
right: -5px;
background: var(--badge-nsfw);
color: white;
font-size: 10px;
padding: 2px 2px;
border-radius: 3px;
line-height: 1;
}
.notif-dropdown {
display: none;
position: absolute;
top: 100%;
left: 50%;
/* Center horizontally relative to bell */
transform: translateX(-50%);
/* Center trick */
width: 300px;
background: var(--dropdown-bg);
border: 1px solid var(--nav-border-color);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.5);
z-index: 1000;
margin-top: 10px;
}
.notif-dropdown.visible {
display: block;
}
.notif-header {
padding: 10px;
border-bottom: 1px solid var(--nav-border-color);
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.9rem;
color: var(--white);
background: var(--nav-bg);
}
.notif-header button {
background: none;
border: none;
color: var(--accent);
cursor: pointer;
font-size: 0.8rem;
padding: 0;
}
.notif-list {
max-height: 300px;
overflow-y: auto;
}
.notif-item {
padding: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
background: var(--dropdown-bg);
cursor: pointer;
transition: background 0.2s;
font-size: 0.85rem;
color: var(--white);
display: block;
text-decoration: none;
}
.notif-item:hover {
background: var(--dropdown-item-hover);
text-decoration: none;
color: var(--white);
}
.notif-item.unread {
border-left: 3px solid var(--accent);
background: rgba(255, 255, 255, 0.02);
}
.notif-time {
font-size: 0.75rem;
color: #888;
margin-top: 3px;
display: block;
}
.notif-empty {
padding: 20px;
text-align: center;
color: #888;
font-size: 0.9rem;
}
#comments-container {
margin-top: 20px;
padding: 10px;
color: var(--white);
max-width: 1000px;
margin-left: auto;
margin-right: auto;
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: 1px solid var(--nav-border-color);
padding-bottom: 10px;
}
.comments-controls {
display: flex;
gap: 10px;
}
.comments-controls button,
.comments-controls select {
background: rgba(255, 255, 255, 0.1);
border: 1px solid var(--nav-border-color);
color: var(--white);
padding: 5px 10px;
cursor: pointer;
border-radius: 0;
/* No rounded corners */
}
.comment-input {
margin-bottom: 20px;
background: rgba(255, 255, 255, 0.05);
/* Light seethrough */
padding: 10px;
border: 1px solid var(--nav-border-color);
border-radius: 0;
/* No rounded corners */
}
.comment-input textarea {
width: 100%;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--nav-border-color);
color: var(--white);
padding: 10px;
min-height: 80px;
resize: vertical;
border-radius: 0;
/* No rounded corners */
}
.input-actions {
margin-top: 10px;
display: flex;
justify-content: flex-end;
align-items: center;
}
.submit-comment {
background: var(--accent);
color: var(--black);
border: none;
padding: 5px 15px;
font-weight: bold;
cursor: pointer;
border-radius: 0;
/* No rounded corners */
}
.cancel-reply {
background: transparent;
color: var(--white);
border: 1px solid var(--nav-border-color);
margin-left: 10px;
padding: 5px 10px;
cursor: pointer;
border-radius: 0;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.comment {
display: flex;
gap: 15px;
padding: 10px;
background: rgba(255, 255, 255, 0.03);
/* Very light seethrough */
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 0;
/* No rounded corners */
}
.comment.deleted {
opacity: 0.5;
}
.comment-avatar img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 0;
/* Square avatars */
}
.comment-body {
flex: 1;
}
.comment-meta {
margin-bottom: 5px;
font-size: 0.9em;
color: #888;
}
.comment-author {
font-weight: bold;
color: var(--accent);
margin-right: 10px;
}
.comment-time {
font-size: 0.8em;
margin-right: 10px;
}
.reply-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 0.8em;
text-decoration: underline;
padding: 0;
}
.comment-content {
line-height: 1.4;
word-break: break-word;
}
.comment-children {
margin-top: 15px;
margin-left: 10px;
border-left: 1px solid var(--nav-border-color);
padding-left: 15px;
}
.loading,
.error {
text-align: center;
padding: 20px;
color: #888;
}
.deleted-msg {
color: #666;
font-style: italic;
}
html[theme="f0ck95"] #tags .badge>a:first-child {
text-shadow: 1px 1px #8080805e;
}
@@ -2237,10 +2496,117 @@ body[type='login'] {
background: var(--bg);
border: 1px solid var(--black);
padding: 5px;
color: #fff;
margin: 6px 0;
/* Markdown Styles */
.comment-content .greentext {
color: #789922;
font-family: monospace;
display: inline-block;
}
.comment-content blockquote {
border-left: 3px solid var(--accent);
margin: 5px 0;
padding-left: 10px;
opacity: 0.8;
}
.comment-content pre {
background: #222;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.comment-content code {
background: #333;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.comment-content ul,
.comment-content ol {
padding-left: 20px;
}
.comment-content a {
text-decoration: underline;
}
margin-bottom: 10px;
border-radius: 0;
width: max-content;
max-width: 300px;
}
/* Emoji Picker */
.input-actions {
position: relative;
/* Context for picker */
}
.emoji-picker {
position: absolute;
bottom: 40px;
right: 0;
width: 350px;
max-height: 300px;
background: var(--dropdown-bg);
border: 1px solid var(--black);
padding: 10px;
z-index: 1000;
overflow-y: auto;
display: flex;
flex-wrap: wrap;
gap: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.5);
border-radius: 8px;
}
.emoji-picker img {
width: 60px;
height: 60px;
object-fit: contain;
cursor: pointer;
transition: transform 0.1s, background 0.1s;
padding: 4px;
border-radius: 4px;
}
.emoji-picker img:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 0.1);
}
.emoji-trigger {
background: none;
border: none;
color: var(--white);
font-size: 20px;
cursor: pointer;
margin-right: 10px;
padding: 0 5px;
vertical-align: middle;
transition: text-shadow 0.2s;
}
.emoji-trigger:hover {
text-shadow: 0 0 8px var(--accent);
}
.emoji-picker::after {
content: '';
position: absolute;
top: 100%;
right: 15px;
border-width: 6px;
border-style: solid;
border-color: var(--dropdown-bg) transparent transparent transparent;
}
/* visualizer */
canvas {
position: absolute;
@@ -3474,4 +3840,192 @@ input#s_avatar {
#register-modal-close:hover {
opacity: 1;
color: var(--accent);
}
/* Comments System */
#comments-container {
margin-top: 20px;
padding: 15px;
background: var(--metadata-bg);
border-radius: 5px;
color: var(--white);
font-family: var(--font);
}
.comments-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
border-bottom: 1px solid var(--gray);
padding-bottom: 10px;
}
.comments-controls button,
.comments-controls select {
background: var(--badge-bg);
border: 1px solid var(--black);
color: var(--white);
padding: 5px 10px;
cursor: pointer;
font-family: inherit;
}
.comment {
margin-bottom: 15px;
padding: 10px;
background: rgba(0, 0, 0, 0.2);
border-radius: 4px;
display: flex;
gap: 15px;
}
.comment.deleted .comment-content {
color: #888;
font-style: italic;
}
.comment-avatar img {
width: 40px;
height: 40px;
border-radius: 4px;
}
.comment-body {
flex: 1;
}
.comment-meta {
font-size: 0.85em;
color: #aaa;
margin-bottom: 5px;
}
.comment-author {
font-weight: bold;
color: var(--accent);
margin-right: 10px;
}
.comment-permalink {
color: #666;
text-decoration: none;
margin-left: 5px;
}
.comment-content {
margin-bottom: 10px;
line-height: 1.4;
word-break: break-word;
}
.reply-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
font-size: 0.9em;
padding: 0;
margin-left: 10px;
}
.reply-btn:hover {
color: var(--white);
text-decoration: underline;
}
.comment-children {
margin-top: 10px;
padding-left: 15px;
border-left: 2px solid var(--gray);
}
.comment-input textarea {
width: 100%;
min-height: 80px;
background: var(--bg);
border: 1px solid var(--gray);
color: var(--white);
padding: 10px;
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.input-actions {
margin-top: 5px;
text-align: right;
}
.submit-comment,
.cancel-reply {
background: var(--accent);
color: var(--black);
border: none;
padding: 6px 12px;
border-radius: 3px;
cursor: pointer;
font-weight: bold;
}
.cancel-reply {
background: #666;
color: white;
margin-left: 5px;
}
.comment-input.reply-input {
margin-top: 10px;
margin-bottom: 10px;
}
.loading,
.error {
text-align: center;
padding: 20px;
color: #888;
}
.login-placeholder {
padding: 15px;
text-align: center;
background: rgba(0, 0, 0, 0.1);
margin-bottom: 15px;
}
/* Markdown Styles */
.comment-content .greentext {
color: #789922;
font-family: monospace;
display: inline-block;
}
.comment-content blockquote {
border-left: 3px solid var(--accent);
margin: 5px 0;
padding-left: 10px;
opacity: 0.8;
}
.comment-content pre {
background: #222;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.comment-content code {
background: #333;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.comment-content ul,
.comment-content ol {
padding-left: 20px;
}
.comment-content a {
text-decoration: underline;
}

431
public/s/js/comments.js Normal file
View File

@@ -0,0 +1,431 @@
class CommentSystem {
constructor() {
this.container = document.getElementById('comments-container');
this.itemId = this.container ? this.container.dataset.itemId : null;
this.user = this.container ? this.container.dataset.user : null; // logged in user?
this.sort = 'new';
if (this.itemId) {
this.init();
}
}
async init() {
await this.loadEmojis();
this.loadComments();
this.setupGlobalListeners();
}
async loadEmojis() {
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;
});
console.log('Loaded Emojis:', this.customEmojis);
} else {
this.customEmojis = {};
}
} catch (e) {
console.error("Failed to load emojis", e);
this.customEmojis = {};
}
}
// ...
renderEmoji(match, name) {
// console.log('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
if (this.customEmojis && this.customEmojis[name]) {
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
}
return match;
}
async loadComments(scrollToId = null) {
if (!this.container) return;
if (!scrollToId) this.container.innerHTML = '<div class="loading">Loading comments...</div>';
try {
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
const data = await res.json();
if (data.success) {
this.render(data.comments, data.user_id, data.is_subscribed);
// Priority: Explicit ID > Hash
if (scrollToId) {
this.scrollToComment(scrollToId);
} else if (window.location.hash && window.location.hash.startsWith('#c')) {
const hashId = window.location.hash.substring(2);
this.scrollToComment(hashId);
}
} else {
this.container.innerHTML = `<div class="error">Failed to load comments: ${data.message}</div>`;
}
} catch (e) {
console.error(e);
this.container.innerHTML = `<div class="error">Error loading comments: ${e.message}</div>`;
}
}
// ...
scrollToComment(id) {
// Allow DOM reflow
setTimeout(() => {
const el = document.getElementById('c' + id);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.transition = "background-color 0.5s";
el.style.backgroundColor = "rgba(255, 255, 0, 0.2)";
setTimeout(() => el.style.backgroundColor = "", 2000);
}
}, 100);
}
render(comments, currentUserId, isSubscribed) {
// Build tree
const map = new Map();
const roots = [];
comments.forEach(c => {
c.children = [];
map.set(c.id, c);
});
comments.forEach(c => {
if (c.parent_id && map.has(c.parent_id)) {
map.get(c.parent_id).children.push(c);
} else {
roots.push(c);
}
});
const subText = isSubscribed ? 'Subscribed' : 'Subscribe';
const subClass = isSubscribed ? 'active' : '';
let html = `
<div class="comments-header">
<h3>Comments (${comments.length})</h3>
<div class="comments-controls">
<select id="comment-sort">
<option value="old" ${this.sort === 'old' ? 'selected' : ''}>Oldest</option>
<option value="new" ${this.sort === 'new' ? 'selected' : ''}>Newest</option>
</select>
${currentUserId ? `<button id="subscribe-btn" class="${subClass}">${subText}</button>` : ''}
<button id="refresh-comments">Refresh</button>
</div>
</div>
${currentUserId ? this.renderInput() : '<div class="login-placeholder"><a href="/login">Login</a> to comment</div>'}
<div class="comments-list">
${roots.map(c => this.renderComment(c, currentUserId)).join('')}
</div>
`;
this.container.innerHTML = html;
this.bindEvents();
}
renderCommentContent(content) {
if (typeof marked === 'undefined') {
console.warn('Marked.js not loaded, falling back to plain text');
return this.escapeHtml(content).replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
}
try {
// 1. Escape HTML, but preserve > for blockquotes
let safe = content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
const renderer = new marked.Renderer();
renderer.blockquote = function (quote) {
// If quote is an object (latest marked), extract text. Otherwise use it as string.
let text = (typeof quote === 'string') ? quote : (quote.text || '');
let cleanQuote = text.replace(/<p>|<\/p>|\n/g, '');
return `<span class="greentext">&gt; ${cleanQuote}</span><br>`;
};
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('Markdown error:', e);
return this.escapeHtml(content);
}
}
renderComment(comment, currentUserId) {
const isDeleted = comment.is_deleted;
const content = isDeleted ? '<span class="deleted-msg">[deleted]</span>' : this.renderCommentContent(comment.content);
const date = new Date(comment.created_at).toLocaleString();
return `
<div class="comment ${isDeleted ? 'deleted' : ''}" id="c${comment.id}">
<div class="comment-avatar">
<img src="${comment.avatar ? `/t/${comment.avatar}.webp` : '/s/img/default.png'}" alt="av">
</div>
<div class="comment-body">
<div class="comment-meta">
<span class="comment-author">${comment.username || 'System'}</span>
<span class="comment-time">${date}</span>
<a href="#c${comment.id}" class="comment-permalink">#${comment.id}</a>
${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}">Reply</button>` : ''}
</div>
<div class="comment-content">${content}</div>
${comment.children.length > 0 ? `<div class="comment-children">${comment.children.map(c => this.renderComment(c, currentUserId)).join('')}</div>` : ''}
</div>
</div>
`;
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
renderInput(parentId = null) {
return `
<div class="comment-input ${parentId ? 'reply-input' : 'main-input'}" ${parentId ? `data-parent="${parentId}"` : ''}>
<textarea placeholder="Write a comment..."></textarea>
<div class="input-actions">
<button class="submit-comment">Post</button>
${parentId ? '<button class="cancel-reply">Cancel</button>' : ''}
</div>
</div>
`;
}
bindEvents() {
// Sorting
const sortSelect = this.container.querySelector('#comment-sort');
if (sortSelect) {
sortSelect.addEventListener('change', (e) => {
this.sort = e.target.value;
this.loadComments();
});
}
// Posting
this.container.querySelectorAll('.submit-comment').forEach(btn => {
btn.addEventListener('click', (e) => this.handleSubmit(e));
});
// Delete
this.container.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
if (!confirm('Delete this comment?')) return;
const id = e.target.dataset.id;
const res = await fetch(`/api/comments/${id}/delete`, { method: 'POST' });
const json = await res.json();
if (json.success) this.loadComments();
else alert('Failed to delete: ' + (json.message || 'Error'));
});
});
// Reply
this.container.querySelectorAll('.reply-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.target.dataset.id;
const body = e.target.closest('.comment-body');
// Check if input already exists
if (body.querySelector('.reply-input')) return;
const div = document.createElement('div');
div.innerHTML = this.renderInput(id);
body.appendChild(div.firstElementChild);
// Bind new buttons
const newForm = body.querySelector('.reply-input');
newForm.querySelector('.submit-comment').addEventListener('click', (ev) => this.handleSubmit(ev));
newForm.querySelector('.cancel-reply').addEventListener('click', () => newForm.remove());
this.setupEmojiPicker(newForm);
});
});
// Main Input Emoji Picker
const mainInput = this.container.querySelector('.main-input');
if (mainInput) this.setupEmojiPicker(mainInput);
// Subscription
// Subscription
const subBtn = this.container.querySelector('#subscribe-btn');
if (subBtn) {
subBtn.addEventListener('click', async () => {
// Optimistic UI update
const isSubscribed = subBtn.textContent === 'Subscribed';
subBtn.textContent = 'Wait...';
try {
const res = await fetch(`/api/subscribe/${this.itemId}`, { method: 'POST' });
const json = await res.json();
if (json.success) {
subBtn.textContent = json.subscribed ? 'Subscribed' : 'Subscribe';
subBtn.classList.toggle('active', json.subscribed);
} else {
// Revert
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
alert('Failed to toggle subscription');
}
} catch (e) {
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
}
});
}
// Refresh
const refBtn = this.container.querySelector('#refresh-comments');
if (refBtn) {
refBtn.addEventListener('click', async () => {
this.loadComments();
});
}
// Permalinks
this.container.addEventListener('click', (e) => {
if (e.target.classList.contains('comment-permalink')) {
e.preventDefault();
const hash = e.target.getAttribute('href'); // #c123
const id = hash.substring(2);
// Update URL without reload/hashchange trigger if possible, or just pushState
history.pushState(null, null, hash);
// Manually scroll
this.scrollToComment(id);
}
});
}
async handleSubmit(e) {
const wrap = e.target.closest('.comment-input');
const text = wrap.querySelector('textarea').value;
const parentId = wrap.dataset.parent || null;
if (!text.trim()) return;
try {
const params = new URLSearchParams();
params.append('item_id', this.itemId);
if (parentId) params.append('parent_id', parentId);
params.append('content', text);
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const json = await res.json();
if (json.success) {
// Refresh comments or append locally
this.loadComments(json.comment.id);
} else {
alert('Error: ' + json.message);
}
} catch (err) {
console.error('Submit Error:', err);
alert('Failed to send comment: ' + err.toString());
}
}
setupGlobalListeners() {
window.addEventListener('hashchange', () => {
if (location.hash && location.hash.startsWith('#c')) {
const id = location.hash.substring(2);
this.scrollToComment(id);
}
});
}
escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
setupEmojiPicker(container) {
const textarea = container.querySelector('textarea');
if (container.querySelector('.emoji-trigger')) return;
const trigger = document.createElement('button');
trigger.innerText = '☺';
trigger.className = 'emoji-trigger';
const actions = container.querySelector('.input-actions');
if (actions) {
actions.prepend(trigger);
trigger.addEventListener('click', (e) => {
e.preventDefault();
let picker = container.querySelector('.emoji-picker');
if (picker) {
picker.remove();
return;
}
picker = document.createElement('div');
picker.className = 'emoji-picker';
if (this.customEmojis && Object.keys(this.customEmojis).length > 0) {
Object.keys(this.customEmojis).forEach(name => {
const url = this.customEmojis[name];
const img = document.createElement('img');
img.src = url;
img.title = `:${name}:`;
img.onclick = (ev) => {
ev.stopPropagation();
textarea.value += ` :${name}: `;
textarea.focus();
};
picker.appendChild(img);
});
} else {
picker.innerHTML = '<div style="padding:5px;color:white;font-size:0.8em;">No emojis found</div>';
}
const closeHandler = (ev) => {
if (!picker.contains(ev.target) && ev.target !== trigger) {
picker.remove();
document.removeEventListener('click', closeHandler);
}
};
setTimeout(() => document.addEventListener('click', closeHandler), 0);
trigger.after(picker);
});
}
}
}
// Global instance or initialization
window.commentSystem = new CommentSystem();
// Re-init on navigation (if using SPA-like/pjax or custom f0ck.js navigation)
document.addEventListener('f0ck:contentLoaded', () => { // Assuming custom event or we hook into it
// f0ck.js probably replaces content. We need to re-init.
window.commentSystem = new CommentSystem();
});
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
// Looking at f0ck.js, it seems to just replace innerHTML.

View File

@@ -411,9 +411,11 @@ window.requestAnimFrame = (function () {
const oldContent = container.querySelector('.content');
const oldMetadata = container.querySelector('.metadata');
const oldHeader = container.querySelector('._204863');
const oldComments = container.querySelector('#comments-container');
if (oldHeader) oldHeader.remove();
if (oldContent) oldContent.remove();
if (oldMetadata) oldMetadata.remove();
if (oldComments) oldComments.remove();
}
}
@@ -445,6 +447,9 @@ window.requestAnimFrame = (function () {
if (navbar) navbar.classList.remove("pbwork");
console.log("AJAX load complete");
// Notify extensions
document.dispatchEvent(new Event('f0ck:contentLoaded'));
} catch (err) {
console.error("AJAX load failed:", err);
}
@@ -658,6 +663,7 @@ window.requestAnimFrame = (function () {
// <wheeler>
const wheelEventListener = function (event) {
if (event.target.closest('.media-object, .steuerung')) {
event.preventDefault(); // Prevent default scroll
if (event.deltaY < 0) {
const el = document.getElementById('next');
if (el && el.href && !el.href.endsWith('#')) el.click();
@@ -668,7 +674,7 @@ window.requestAnimFrame = (function () {
}
};
window.addEventListener('wheel', wheelEventListener);
window.addEventListener('wheel', wheelEventListener, { passive: false });
// </wheeler>
@@ -687,7 +693,7 @@ window.requestAnimFrame = (function () {
f0ckimagescroll.removeAttribute("style");
f0ckimage.removeAttribute("style");
console.log("image is not expanded")
window.addEventListener('wheel', wheelEventListener);
window.addEventListener('wheel', wheelEventListener, { passive: false });
} else {
if (img.width > img.height) return;
isImageExpanded = true;
@@ -762,7 +768,8 @@ window.requestAnimFrame = (function () {
if (data.success && data.html) {
// Append new thumbnails
postsContainer.insertAdjacentHTML('beforeend', data.html);
const currentPosts = document.querySelector("div.posts");
if (currentPosts) currentPosts.insertAdjacentHTML('beforeend', data.html);
// Update state
infiniteState.currentPage = data.currentPage;
@@ -796,6 +803,9 @@ window.requestAnimFrame = (function () {
const PRELOAD_OFFSET = 500; // pixels before bottom to trigger load
window.addEventListener("scroll", () => {
const currentContainer = document.querySelector("div.posts");
// Only run if .posts exists (index/grid view)
if (!currentContainer) return;
if (!document.querySelector('#main')) return;
const scrollPosition = window.innerHeight + window.scrollY;
@@ -949,32 +959,6 @@ window.requestAnimFrame = (function () {
// </scroller>
})();
// disable default scroll event when mouse is on content div
// this is useful for items that have a lot of tags for example: 12536
const targetSelector = '.content';
let isMouseOver = true;
function isPageScrollable() {
return document.documentElement.scrollHeight > document.documentElement.clientHeight;
}
function onWheel(e) {
if (isMouseOver && isPageScrollable()) {
e.preventDefault();
}
}
function init() {
const el = document.querySelector(targetSelector);
if (!el) return;
el.addEventListener('mouseenter', () => isMouseOver = true);
el.addEventListener('mouseleave', () => isMouseOver = false);
window.addEventListener('wheel', onWheel, { passive: false });
}
window.addEventListener('load', init);
const sbtForm = document.getElementById('sbtForm');
if (sbtForm) {
sbtForm.addEventListener('submit', (e) => {
@@ -982,6 +966,105 @@ if (sbtForm) {
const input = document.getElementById('sbtInput').value.trim();
if (input) {
window.location.href = `/tag/${encodeURIComponent(input)}`;
}
});
}
// Notification System
class NotificationSystem {
constructor() {
this.bell = document.getElementById('nav-notif-btn');
this.dropdown = document.getElementById('notif-dropdown');
this.countBadge = this.bell ? this.bell.querySelector('.notif-count') : null;
this.list = this.dropdown ? this.dropdown.querySelector('.notif-list') : null;
this.markAllBtn = document.getElementById('mark-all-read');
if (this.bell && this.dropdown) {
this.init();
}
}
init() {
this.bindEvents();
this.poll();
setInterval(() => this.poll(), 60000); // Poll every minute
}
bindEvents() {
this.bell.addEventListener('click', (e) => {
e.preventDefault();
this.dropdown.classList.toggle('visible');
});
// Close on click outside
document.addEventListener('click', (e) => {
if (!this.bell.contains(e.target) && !this.dropdown.contains(e.target)) {
this.dropdown.classList.remove('visible');
}
});
if (this.markAllBtn) {
this.markAllBtn.addEventListener('click', async () => {
await fetch('/api/notifications/read', { method: 'POST' });
this.markAllReadUI();
});
}
}
async poll() {
try {
const res = await fetch('/api/notifications');
const data = await res.json();
if (data.success) {
this.updateUI(data.notifications);
}
} catch (e) {
console.error('Notification poll error', e);
}
}
updateUI(notifications) {
const unreadCount = notifications.filter(n => !n.is_read).length;
if (unreadCount > 0) {
this.countBadge.textContent = unreadCount;
this.countBadge.style.display = 'block';
} else {
this.countBadge.style.display = 'none';
}
if (notifications.length === 0) {
this.list.innerHTML = '<div class="notif-empty">No new notifications</div>';
return;
}
this.list.innerHTML = notifications.map(n => this.renderItem(n)).join('');
}
renderItem(n) {
const typeText = n.type === 'comment_reply' ? 'replied to your comment' : 'Start';
const cid = n.comment_id || n.reference_id;
const link = `/${n.item_id}#c${cid}`;
return `
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" onclick="window.location.href='${link}'; return false;">
<div>
<strong>${n.from_user}</strong> ${typeText}
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
</a>
`;
}
markAllReadUI() {
this.countBadge.style.display = 'none';
this.list.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
}
}
// Init Notifications
window.addEventListener('load', () => {
new NotificationSystem();
});

69
public/s/js/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long