feat: Implement comments, notifications, and custom emojis with new API routes, UI components, and database migrations.
This commit is contained in:
22
debug/init_comments.mjs
Normal file
22
debug/init_comments.mjs
Normal 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
34
debug/init_emojis.mjs
Normal 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
106
debug/verify_comments.mjs
Normal 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
62
debug/verify_db.mjs
Normal 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
37
migration_comments.sql
Normal 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;
|
||||
@@ -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
186
src/inc/routes/comments.mjs
Normal file
186
src/inc/routes/comments.mjs
Normal 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
85
src/inc/routes/emojis.mjs
Normal 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;
|
||||
};
|
||||
67
src/inc/routes/notifications.mjs
Normal file
67
src/inc/routes/notifications.mjs
Normal 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;
|
||||
};
|
||||
@@ -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
96
views/admin/emojis.html
Normal 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)
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user