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

22
debug/init_comments.mjs Normal file
View File

@@ -0,0 +1,22 @@
import db from "../src/inc/sql.mjs";
import { promises as fs } from "fs";
(async () => {
try {
const migration = await fs.readFile("./migration_comments.sql", "utf-8");
console.log("Applying migration...");
// Split by semicolon to handle multiple statements if the driver requires it,
// but postgres.js usually handles simple files well or we can execute as one block
// if it's just DDL. However, postgres.js template literal usually prefers single statements
// or we can use `db.file` if available, or just execute the string.
// Simple approach: execute the whole string
await db.unsafe(migration);
console.log("Migration applied successfully.");
process.exit(0);
} catch (e) {
console.error("Migration failed:", e);
process.exit(1);
}
})();

34
debug/init_emojis.mjs Normal file
View File

@@ -0,0 +1,34 @@
import db from "../src/inc/sql.mjs";
async function run() {
try {
console.log("Creating custom_emojis table...");
await db`
CREATE TABLE IF NOT EXISTS custom_emojis (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
url TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
`;
// Seed with existing default emojis if table is empty
const count = await db`SELECT count(*) FROM custom_emojis`;
if (count[0].count == 0) {
console.log("Seeding default emojis...");
await db`
INSERT INTO custom_emojis (name, url) VALUES
('f0ck', '/s/img/f0ck.png')
`;
}
console.log("Done.");
process.exit(0);
} catch (e) {
console.error(e);
process.exit(1);
}
}
run();

106
debug/verify_comments.mjs Normal file
View File

@@ -0,0 +1,106 @@
import db from "../src/inc/sql.mjs";
import http from "http";
import crypto from "crypto";
const HOST = "localhost";
const PORT = 3000;
import { readFile } from "fs/promises";
const cfg = JSON.parse(await readFile("../config.json", "utf8"));
const serverPort = cfg.websrv.port;
const runTest = async () => {
// 1. Setup Data
console.log("Setting up test data...");
const user = await db`SELECT id FROM "user" LIMIT 1`;
const item = await db`SELECT id FROM "items" LIMIT 1`;
if (!user.length || !item.length) {
console.error("No user or item found.");
process.exit(1);
}
const userId = user[0].id;
const itemId = item[0].id;
// Create session
const sessionKey = "testsession_" + Date.now();
const sessionHash = crypto.createHash('md5').update(sessionKey).digest("hex");
await db`DELETE FROM user_sessions WHERE user_id = ${userId}`; // Clear old sessions for clean test
await db`INSERT INTO user_sessions (user_id, session, browser, created_at, last_used, last_action)
VALUES (${userId}, ${sessionHash}, 'test-bot', ${Math.floor(Date.now() / 1000)}, ${Math.floor(Date.now() / 1000)}, 'test')`;
console.log(`User: ${userId}, Item: ${itemId}, Session: ${sessionKey}`);
// Helper for requests
const request = (method, path, body = null, cookie = null) => {
return new Promise((resolve, reject) => {
const options = {
hostname: HOST,
port: serverPort,
path: path,
method: method,
headers: {
'Content-Type': 'application/json',
'Cookie': cookie ? `session=${cookie}` : ''
}
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
};
// 2. Test GET (Empty)
console.log("Testing GET /api/comments/" + itemId);
let res = await request('GET', `/api/comments/${itemId}`);
console.log("GET Response:", res.body);
let json = JSON.parse(res.body);
if (!json.success) throw new Error("GET failed");
// 3. Test POST
console.log("Testing POST /api/comments");
res = await request('POST', '/api/comments', {
item_id: itemId,
content: "Hello World from Test Bot"
}, sessionKey);
console.log("POST Response:", res.body);
json = JSON.parse(res.body);
if (!json.success) throw new Error("POST failed");
const commentId = json.comment.id;
// 4. Test GET (With comment)
console.log("Testing GET /api/comments/" + itemId);
res = await request('GET', `/api/comments/${itemId}`);
json = JSON.parse(res.body);
if (json.comments.length === 0) throw new Error("Comment not found");
console.log("Found comments:", json.comments.length);
// 5. Test Subscribe
console.log("Testing POST /api/subscribe/" + itemId);
res = await request('POST', `/api/subscribe/${itemId}`, {}, sessionKey);
console.log("Subscribe Response:", res.body);
json = JSON.parse(res.body);
if (!json.success) throw new Error("Subscribe failed");
if (!json.subscribed) throw new Error("Expected subscribed=true");
console.log("Testing Unsubscribe...");
res = await request('POST', `/api/subscribe/${itemId}`, {}, sessionKey);
json = JSON.parse(res.body);
if (json.subscribed) throw new Error("Expected subscribed=false");
console.log("ALL TESTS PASSED");
process.exit(0);
};
runTest().catch(e => {
console.error(e);
process.exit(1);
});

62
debug/verify_db.mjs Normal file
View File

@@ -0,0 +1,62 @@
import db from "../src/inc/sql.mjs";
const runTest = async () => {
console.log("Verifying Database Schema...");
// 1. Check Tables
try {
await db`SELECT 1 FROM comments LIMIT 1`;
console.log("✔ Table 'comments' exists.");
} catch (e) {
console.error("✘ Table 'comments' missing.");
process.exit(1);
}
try {
await db`SELECT 1 FROM comment_subscriptions LIMIT 1`;
console.log("✔ Table 'comment_subscriptions' exists.");
} catch (e) {
console.error("✘ Table 'comment_subscriptions' missing.");
process.exit(1);
}
// 2. Insert Test Data
console.log("Testing Insert...");
const user = await db`SELECT id FROM "user" LIMIT 1`;
const item = await db`SELECT id FROM "items" LIMIT 1`;
if (!user.length || !item.length) {
console.log("⚠ No user/item to test insert. Skipping.");
} else {
const userId = user[0].id;
const itemId = item[0].id;
const comment = await db`
INSERT INTO comments (item_id, user_id, content)
VALUES (${itemId}, ${userId}, 'Test Comment DB Verify')
RETURNING id
`;
console.log("✔ Inserted comment ID:", comment[0].id);
const fetch = await db`SELECT content FROM comments WHERE id = ${comment[0].id}`;
if (fetch[0].content === 'Test Comment DB Verify') {
console.log("✔ Verified content.");
} else {
console.error("✘ Content mismatch.");
process.exit(1);
}
// Cleanup
await db`DELETE FROM comments WHERE id = ${comment[0].id}`;
}
console.log("DB Schema Verification Passed.");
// 3. Optional: subscription test
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${user[0].id}, ${item[0].id}) ON CONFLICT DO NOTHING`;
console.log("✔ Subscription table write access confirmed.");
process.exit(0);
};
runTest().catch(console.error);

37
migration_comments.sql Normal file
View File

@@ -0,0 +1,37 @@
-- Create comments table
CREATE TABLE IF NOT EXISTS comments (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES comments(id) ON DELETE SET NULL,
content TEXT NOT NULL,
is_deleted BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE,
vote_score INTEGER DEFAULT 0
);
-- Create notifications table
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
type VARCHAR(32) NOT NULL, -- 'reply', 'comment', 'mention'
reference_id INTEGER NOT NULL, -- ID of the comment
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Create comment_subscriptions table (for subscribing to posts)
CREATE TABLE IF NOT EXISTS comment_subscriptions (
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (user_id, item_id)
);
-- Indexes for performance
CREATE INDEX idx_comments_item_id ON comments(item_id);
CREATE INDEX idx_comments_user_id ON comments(user_id);
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
CREATE INDEX idx_notifications_unread ON notifications(user_id) WHERE is_read = FALSE;

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

186
src/inc/routes/comments.mjs Normal file
View File

@@ -0,0 +1,186 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs"; // Assuming this exists or we need to check imports
export default (router, tpl) => {
// Fetch comments for an item
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
const itemId = req.params.itemid;
const sort = req.url.qs?.sort || 'new'; // 'new' or 'old'
try {
const comments = await db`
SELECT
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
u.user as username, u.id as user_id, uo.avatar,
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
FROM comments c
JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE c.item_id = ${itemId}
ORDER BY c.created_at ${db.unsafe(sort === 'new' ? 'DESC' : 'ASC')}
`;
let is_subscribed = false;
if (req.session) {
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
if (sub.length > 0) is_subscribed = true;
}
// Transform for frontend if needed, or send as is
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
comments,
is_subscribed,
user_id: req.session ? req.session.user : null,
is_admin: req.session ? req.session.admin : false
})
})
} catch (err) {
console.error(err);
return res.reply({
code: 500,
body: JSON.stringify({ success: false, message: "Database 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" }) });
console.log("DEBUG: POST /api/comments");
// Use standard framework parsing
const body = req.post || {};
const item_id = parseInt(body.item_id, 10);
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
const content = body.content;
console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
if (!content || !content.trim()) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
}
try {
const newComment = await db`
INSERT INTO comments ${db({
item_id,
user_id: req.session.id,
parent_id: parent_id || null,
content: content
})}
RETURNING id, created_at
`;
const commentId = parseInt(newComment[0].id, 10);
// Notify Subscribers (excluding the author)
// 1. Get subscribers of the item
// 2. If it's a reply, notify parent author? (Optional, complex logic. Let's stick to item subscription for now + Parent author)
// Logic: Notify users who subscribed to this item OR are the parent author.
// Exclude current user.
// 1. Get subscribers
const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id}`;
// 2. Get parent author
let parentAuthor = [];
if (parent_id) {
parentAuthor = await db`SELECT user_id FROM comments WHERE id = ${parent_id}`;
}
// 3. Collect unique recipients
const recipients = new Set();
subscribers.forEach(s => recipients.add(s.user_id));
parentAuthor.forEach(p => recipients.add(p.user_id));
// Remove self
recipients.delete(req.session.id);
// 4. Batch insert
if (recipients.size > 0) {
const notificationsToAdd = Array.from(recipients).map(uid => ({
user_id: uid,
type: 'comment_reply',
item_id: item_id, // Already parsed as int
comment_id: commentId, // Already parsed as int
reference_id: commentId // Already parsed as int
}));
await db`INSERT INTO notifications ${db(notificationsToAdd)}`;
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, comment: newComment[0] })
});
} catch (err) {
console.error(err);
return res.reply({
code: 500,
body: JSON.stringify({ success: false, message: "Database error" })
});
}
});
// Subscribe toggle
router.post(/\/api\/subscribe\/(?<itemid>\d+)/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const itemId = req.params.itemid;
try {
const existing = await db`
SELECT 1 FROM comment_subscriptions
WHERE user_id = ${req.session.id} AND item_id = ${itemId}
`;
let subscribed = false;
if (existing.length > 0) {
await db`DELETE FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
} else {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemId})`;
subscribed = true;
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, subscribed })
});
} catch (e) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Delete comment
router.post(/\/api\/comments\/(?<id>\d+)\/delete/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const commentId = req.params.id;
try {
const comment = await db`SELECT user_id FROM comments WHERE id = ${commentId}`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
if (!req.session.admin && comment[0].user_id !== req.session.id) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true })
});
} catch (e) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router;
};

85
src/inc/routes/emojis.mjs Normal file
View File

@@ -0,0 +1,85 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
export default (router, tpl) => {
// Admin View
router.get(/^\/admin\/emojis\/?$/, lib.auth, async (req, res) => {
res.reply({
body: tpl.render("admin/emojis", { session: req.session, tmp: null }, req)
});
});
// List all emojis (Public)
router.get('/api/v2/emojis', async (req, res) => {
try {
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, emojis })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
// Add emoji (Admin only)
router.post('/api/v2/admin/emojis', async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
const body = req.post || {};
const name = body.name ? body.name.trim().toLowerCase() : '';
const url = body.url ? body.url.trim() : '';
if (!name || !url) {
return res.reply({ body: JSON.stringify({ success: false, message: "Name and URL required" }) });
}
// Basic name validation (alphanumeric)
if (!/^[a-z0-9_]+$/.test(name)) {
return res.reply({ body: JSON.stringify({ success: false, message: "Invalid name. Use lowercase a-z, 0-9, _ only." }) });
}
try {
const newEmoji = await db`
INSERT INTO custom_emojis (name, url) VALUES (${name}, ${url})
RETURNING id, name, url
`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, emoji: newEmoji[0] })
});
} catch (e) {
if (e.code === '23505') { // Unique violation
return res.reply({ body: JSON.stringify({ success: false, message: "Emoji name already exists" }) });
}
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
// Delete emoji (Admin only)
router.post(/\/api\/v2\/admin\/emojis\/(?<id>\d+)\/delete/, async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
const id = req.params.id;
try {
await db`DELETE FROM custom_emojis WHERE id = ${id}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
return router;
};

View File

@@ -0,0 +1,67 @@
import db from "../sql.mjs";
export default (router, tpl) => {
// Get unread notifications
router.get('/api/notifications', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
try {
const notifications = await db`
SELECT n.id, n.type, n.item_id, n.comment_id, n.reference_id, n.created_at, n.is_read,
u.user as from_user, u.id as from_user_id
FROM notifications n
-- Join on reference_id (which is comment_id) or comment_id.
-- Since we just set both, let's join on comment_id if present, fallback to reference_id?
-- The join was: JOIN comments c ON n.comment_id = c.id
-- If comment_id was null before my fix, this join would fail for old notifs.
-- Let's assume we use reference_id as the ID for now.
JOIN comments c ON (n.comment_id = c.id OR n.reference_id = c.id)
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
`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, notifications })
});
} catch (err) {
console.error(err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Mark all as read
router.post('/api/notifications/read', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
try {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true })
});
} catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Mark single as read (optional, for clicking)
router.post(/\/api\/notifications\/(?<id>\d+)\/read/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const id = req.params.id;
try {
await db`UPDATE notifications SET is_read = true WHERE id = ${id} AND user_id = ${req.session.id}`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true })
});
} catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router;
};

View File

@@ -17,6 +17,7 @@
<li><a href="/admin/approve">Approval Queue</a></li>
<li><a href="/admin/sessions">Sessions</a></li>
<li><a href="/admin/tokens">Invite Tokens</a></li>
<li><a href="/admin/emojis">Emoji Manager</a></li>
</ul>
</div>
</div>

96
views/admin/emojis.html Normal file
View File

@@ -0,0 +1,96 @@
@include(snippets/header)
<div class="container" style="padding-top: 20px;">
<h2>Custom Emojis</h2>
<div class="upload-form"
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
<h4>Add New Emoji</h4>
<input type="text" id="emoji-name" placeholder="Name (e.g. pingu)"
style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
<input type="text" id="emoji-url" placeholder="URL (e.g. /s/img/pingu.gif)"
style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white); width: 300px;">
<button id="add-emoji" class="btn-upload"
style="width: auto; padding: 5px 15px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
</div>
<div class="upload-form" style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; color: var(--white);">
<thead>
<tr style="border-bottom: 1px solid var(--nav-border-color); text-align: left;">
<th style="padding: 10px;">Preview</th>
<th style="padding: 10px;">Name</th>
<th style="padding: 10px;">URL</th>
<th style="padding: 10px;">Actions</th>
</tr>
</thead>
<tbody id="emoji-list">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<script>
const loadEmojis = async () => {
try {
const res = await fetch('/api/v2/emojis');
const data = await res.json();
if (data.success) {
const tbody = document.getElementById('emoji-list');
tbody.innerHTML = data.emojis.map(e =>
'<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">' +
'<td style="padding: 10px;"><img src="' + e.url + '" style="height: 30px; object-fit: contain;"></td>' +
'<td style="padding: 10px; font-family: monospace; font-size: 1.1em; color: var(--accent);">:' + e.name + ':</td>' +
'<td style="padding: 10px; opacity: 0.7;">' + e.url + '</td>' +
'<td style="padding: 10px;">' +
'<button onclick="deleteEmoji(' + e.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em; background: #c00; color: white; border: none; cursor: pointer;">Delete</button>' +
'</td>' +
'</tr>'
).join('');
}
} catch (err) { console.error(err); }
};
const addEmoji = async () => {
const name = document.getElementById('emoji-name').value;
const url = document.getElementById('emoji-url').value;
if (!name || !url) return alert('Fill both fields');
try {
const res = await fetch('/api/v2/admin/emojis', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // Body parsing middleware uses this or JSON? Typically form-encoded in this stack
body: new URLSearchParams({ name, url })
});
const data = await res.json();
if (data.success) {
document.getElementById('emoji-name').value = '';
document.getElementById('emoji-url').value = '';
loadEmojis();
} else {
alert('Failed: ' + data.message);
}
} catch (e) {
alert('Error: ' + e.message);
}
};
const deleteEmoji = async (id) => {
if (!confirm('Delete this emoji?')) return;
try {
const res = await fetch('/api/v2/admin/emojis/' + id + '/delete', { method: 'POST' });
const data = await res.json();
if (data.success) {
loadEmojis();
} else {
alert('Failed');
}
} catch (e) { alert(e); }
};
document.getElementById('add-emoji').addEventListener('click', addEmoji);
loadEmojis();
</script>
@include(snippets/footer)

View File

@@ -95,10 +95,12 @@
<span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a>
@if(session)
(<a id="a_username" href="/user/{{ user.name.toLowerCase() }}/f0cks@if(tmp.mime)/{{ tmp.mime }}@endif">{{ user.name }}</a>)
(<a id="a_username"
href="/user/{{ user.name.toLowerCase() }}/f0cks@if(tmp.mime)/{{ tmp.mime }}@endif">{{user.name }}</a>)
@endif
</span>
<span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{ item.timestamp.timeago }}</time></span>
<span class="badge badge-dark"><time class="timeago"
tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span>
<span class="badge badge-dark" id="tags">
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
@@ -121,6 +123,6 @@
style="height: 32px; width: 32px" /></a>
@endeach
@endif
</span>
</div>
</div>
</div>
<div id="comments-container" data-item-id="{{ item.id }}" @if(session)data-user="{{ session.user }}" @endif></div>

View File

@@ -22,6 +22,7 @@
<script async src="/s/js/theme.js?v=@mtime(/public/s/js/theme.js)"></script>
<script src="/s/js/v0ck.js?v=@mtime(/public/s/js/v0ck.js)"></script>
<script src="/s/js/f0ck.js?v=@mtime(/public/s/js/f0ck.js)"></script>
<script src="/s/js/comments.js?v=@mtime(/public/s/js/comments.js)"></script>
@if(session && session.admin)
<script src="/s/js/admin.js?v=@mtime(/public/s/js/admin.js)"></script>
@elseif(session && !session.admin)

View File

@@ -7,6 +7,8 @@
<link rel="icon" type="image/gif" href="/s/img/favicon.png" />
<link rel="stylesheet" href="/s/css/f0ck.css?v=@mtime(/public/s/css/f0ck.css)">
<link rel="stylesheet" href="/s/css/w0bm.css?v=@mtime(/public/s/css/w0bm.css)">
@if(typeof item !== 'undefined')
<script src="/s/js/marked.min.js"></script>@endif
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@if(typeof item !== 'undefined')

View File

@@ -36,6 +36,24 @@
d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z" />
</svg></a>
@endif
<div id="nav-notifications" class="nav-item-rel">
<a href="#" id="nav-notif-btn" title="Notifications">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16">
<path
d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zm.995-14.901a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z" />
</svg>
<span class="notif-count" style="display:none">0</span>
</a>
<div id="notif-dropdown" class="notif-dropdown">
<div class="notif-header">
<span>Notifications</span>
<button id="mark-all-read">Mark all read</button>
</div>
<div class="notif-list">
<div class="notif-empty">No new notifications</div>
</div>
</div>
</div>
<a href="#" id="nav-search-btn" title="Search"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
fill="currentColor" viewBox="0 0 16 16">
<path