feat: Implement comments, notifications, and custom emojis with new API routes, UI components, and database migrations.
This commit is contained in:
@@ -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
431
public/s/js/comments.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
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">> ${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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
|
||||
|
||||
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.
|
||||
@@ -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
69
public/s/js/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user