diff --git a/debug/init_comments.mjs b/debug/init_comments.mjs
new file mode 100644
index 0000000..b93d812
--- /dev/null
+++ b/debug/init_comments.mjs
@@ -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);
+ }
+})();
diff --git a/debug/init_emojis.mjs b/debug/init_emojis.mjs
new file mode 100644
index 0000000..5819184
--- /dev/null
+++ b/debug/init_emojis.mjs
@@ -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();
diff --git a/debug/verify_comments.mjs b/debug/verify_comments.mjs
new file mode 100644
index 0000000..1b9bc90
--- /dev/null
+++ b/debug/verify_comments.mjs
@@ -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);
+});
diff --git a/debug/verify_db.mjs b/debug/verify_db.mjs
new file mode 100644
index 0000000..e99ef6d
--- /dev/null
+++ b/debug/verify_db.mjs
@@ -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);
diff --git a/migration_comments.sql b/migration_comments.sql
new file mode 100644
index 0000000..f9a8a60
--- /dev/null
+++ b/migration_comments.sql
@@ -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;
diff --git a/public/s/css/f0ck.css b/public/s/css/f0ck.css
index c8ecb30..c13fceb 100644
--- a/public/s/css/f0ck.css
+++ b/public/s/css/f0ck.css
@@ -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;
}
\ No newline at end of file
diff --git a/public/s/js/comments.js b/public/s/js/comments.js
new file mode 100644
index 0000000..37b770e
--- /dev/null
+++ b/public/s/js/comments.js
@@ -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 ` `;
+ }
+ return match;
+ }
+
+ async loadComments(scrollToId = null) {
+ if (!this.container) return;
+ if (!scrollToId) this.container.innerHTML = '
Loading comments...
';
+
+ 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 = `Failed to load comments: ${data.message}
`;
+ }
+ } catch (e) {
+ console.error(e);
+ this.container.innerHTML = `Error loading comments: ${e.message}
`;
+ }
+ }
+
+ // ...
+
+
+
+ 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 = `
+
+ ${currentUserId ? this.renderInput() : ''}
+
+ `;
+
+ 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(/|<\/p>|\n/g, '');
+ return `> ${cleanQuote} `;
+ };
+
+ 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 ? '[deleted] ' : this.renderCommentContent(comment.content);
+ const date = new Date(comment.created_at).toLocaleString();
+
+ return `
+
+ `;
+ }
+
+ escapeHtml(unsafe) {
+ return unsafe
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+ }
+
+ renderInput(parentId = null) {
+ return `
+
+
+
+
+ ${parentId ? 'Cancel ' : ''}
+
+
+ `;
+ }
+
+ 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, "'");
+ }
+
+
+
+ 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 = 'No emojis found
';
+ }
+
+ 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.
diff --git a/public/s/js/f0ck.js b/public/s/js/f0ck.js
index eb9672d..5d66b85 100644
--- a/public/s/js/f0ck.js
+++ b/public/s/js/f0ck.js
@@ -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 () {
//
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 });
//
@@ -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 () {
//
})();
-
-// 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 = 'No new notifications
';
+ 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 `
+
+
+ ${n.from_user} ${typeText}
+
+ ${new Date(n.created_at).toLocaleString()}
+
+ `;
+ }
+
+ 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();
+});
+
diff --git a/public/s/js/marked.min.js b/public/s/js/marked.min.js
new file mode 100644
index 0000000..b4e0d73
--- /dev/null
+++ b/public/s/js/marked.min.js
@@ -0,0 +1,69 @@
+/**
+ * marked v15.0.12 - a markdown parser
+ * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed)
+ * https://github.com/markedjs/marked
+ */
+
+/**
+ * DO NOT EDIT THIS FILE
+ * The code in this file is generated from files in ./src/
+ */
+(function(g,f){if(typeof exports=="object"&&typeof module<"u"){module.exports=f()}else if("function"==typeof define && define.amd){define("marked",f)}else {g["marked"]=f()}}(typeof globalThis < "u" ? globalThis : typeof self < "u" ? self : this,function(){var exports={};var __exports=exports;var module={exports};
+"use strict";var H=Object.defineProperty;var be=Object.getOwnPropertyDescriptor;var Te=Object.getOwnPropertyNames;var we=Object.prototype.hasOwnProperty;var ye=(l,e)=>{for(var t in e)H(l,t,{get:e[t],enumerable:!0})},Re=(l,e,t,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let s of Te(e))!we.call(l,s)&&s!==t&&H(l,s,{get:()=>e[s],enumerable:!(n=be(e,s))||n.enumerable});return l};var Se=l=>Re(H({},"__esModule",{value:!0}),l);var kt={};ye(kt,{Hooks:()=>L,Lexer:()=>x,Marked:()=>E,Parser:()=>b,Renderer:()=>$,TextRenderer:()=>_,Tokenizer:()=>S,defaults:()=>w,getDefaults:()=>z,lexer:()=>ht,marked:()=>k,options:()=>it,parse:()=>pt,parseInline:()=>ct,parser:()=>ut,setOptions:()=>ot,use:()=>lt,walkTokens:()=>at});module.exports=Se(kt);function z(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var w=z();function N(l){w=l}var I={exec:()=>null};function h(l,e=""){let t=typeof l=="string"?l:l.source,n={replace:(s,i)=>{let r=typeof i=="string"?i:i.source;return r=r.replace(m.caret,"$1"),t=t.replace(s,r),n},getRegex:()=>new RegExp(t,e)};return n}var m={codeRemoveIndent:/^(?: {1,4}| {0,3}\t)/gm,outputLinkReplace:/\\([\[\]])/g,indentCodeCompensation:/^(\s+)(?:```)/,beginningSpace:/^\s+/,endingHash:/#$/,startingSpaceChar:/^ /,endingSpaceChar:/ $/,nonSpaceChar:/[^ ]/,newLineCharGlobal:/\n/g,tabCharGlobal:/\t/g,multipleSpaceGlobal:/\s+/g,blankLine:/^[ \t]*$/,doubleBlankLine:/\n[ \t]*\n[ \t]*$/,blockquoteStart:/^ {0,3}>/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^,endAngleBracket:/>$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:l=>new RegExp(`^( {0,3}${l})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}#`),htmlBeginRegex:l=>new RegExp(`^ {0,${Math.min(3,l-1)}}<(?:[a-z].*>|!--)`,"i")},$e=/^(?:[ \t]*(?:\n|$))+/,_e=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Le=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,O=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,ze=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,F=/(?:[*+-]|\d{1,9}[.)])/,ie=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,oe=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Me=h(ie).replace(/bull/g,F).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),Q=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Pe=/^[^\n]+/,U=/(?!\s*\])(?:\\.|[^\[\]\\])+/,Ae=h(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",U).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),Ee=h(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,F).getRegex(),v="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",K=/|$))/,Ce=h("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",K).replace("tag",v).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),le=h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Ie=h(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",le).getRegex(),X={blockquote:Ie,code:_e,def:Ae,fences:Le,heading:ze,hr:O,html:Ce,lheading:oe,list:Ee,newline:$e,paragraph:le,table:I,text:Pe},re=h("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex(),Oe={...X,lheading:Me,table:re,paragraph:h(Q).replace("hr",O).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",re).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html","?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| \\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",K).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:I,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:h(Q).replace("hr",O).replace("heading",` *#{1,6} *[^
+]`).replace("lheading",oe).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},qe=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,ve=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,ae=/^( {2,}|\\)\n(?!\s*$)/,De=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\]*?>/g,ue=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,je=h(ue,"u").replace(/punct/g,D).getRegex(),Fe=h(ue,"u").replace(/punct/g,pe).getRegex(),he="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",Qe=h(he,"gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Ue=h(he,"gu").replace(/notPunctSpace/g,He).replace(/punctSpace/g,Ge).replace(/punct/g,pe).getRegex(),Ke=h("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,ce).replace(/punctSpace/g,W).replace(/punct/g,D).getRegex(),Xe=h(/\\(punct)/,"gu").replace(/punct/g,D).getRegex(),We=h(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Je=h(K).replace("(?:-->|$)","-->").getRegex(),Ve=h("^comment|^[a-zA-Z][\\w:-]*\\s*>|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Je).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),q=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,Ye=h(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",q).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),ke=h(/^!?\[(label)\]\[(ref)\]/).replace("label",q).replace("ref",U).getRegex(),ge=h(/^!?\[(ref)\](?:\[\])?/).replace("ref",U).getRegex(),et=h("reflink|nolink(?!\\()","g").replace("reflink",ke).replace("nolink",ge).getRegex(),J={_backpedal:I,anyPunctuation:Xe,autolink:We,blockSkip:Ne,br:ae,code:ve,del:I,emStrongLDelim:je,emStrongRDelimAst:Qe,emStrongRDelimUnd:Ke,escape:qe,link:Ye,nolink:ge,punctuation:Ze,reflink:ke,reflinkSearch:et,tag:Ve,text:De,url:I},tt={...J,link:h(/^!?\[(label)\]\((.*?)\)/).replace("label",q).getRegex(),reflink:h(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",q).getRegex()},j={...J,emStrongRDelimAst:Ue,emStrongLDelim:Fe,url:h(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/,"i").replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/,text:/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},fe=l=>st[l];function R(l,e){if(e){if(m.escapeTest.test(l))return l.replace(m.escapeReplace,fe)}else if(m.escapeTestNoEncode.test(l))return l.replace(m.escapeReplaceNoEncode,fe);return l}function V(l){try{l=encodeURI(l).replace(m.percentDecode,"%")}catch{return null}return l}function Y(l,e){let t=l.replace(m.findPipe,(i,r,o)=>{let a=!1,c=r;for(;--c>=0&&o[c]==="\\";)a=!a;return a?"|":" |"}),n=t.split(m.splitPipe),s=0;if(n[0].trim()||n.shift(),n.length>0&&!n.at(-1)?.trim()&&n.pop(),e)if(n.length>e)n.splice(e);else for(;n.length0?-2:-1}function me(l,e,t,n,s){let i=e.href,r=e.title||null,o=l[1].replace(s.other.outputLinkReplace,"$1");n.state.inLink=!0;let a={type:l[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:r,text:o,tokens:n.inlineTokens(o)};return n.state.inLink=!1,a}function rt(l,e,t){let n=l.match(t.other.indentCodeCompensation);if(n===null)return e;let s=n[1];return e.split(`
+`).map(i=>{let r=i.match(t.other.beginningSpace);if(r===null)return i;let[o]=r;return o.length>=s.length?i.slice(s.length):i}).join(`
+`)}var S=class{options;rules;lexer;constructor(e){this.options=e||w}space(e){let t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){let t=this.rules.block.code.exec(e);if(t){let n=t[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?n:A(n,`
+`)}}}fences(e){let t=this.rules.block.fences.exec(e);if(t){let n=t[0],s=rt(n,t[3]||"",this.rules);return{type:"code",raw:n,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:s}}}heading(e){let t=this.rules.block.heading.exec(e);if(t){let n=t[2].trim();if(this.rules.other.endingHash.test(n)){let s=A(n,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(n=s.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:n,tokens:this.lexer.inline(n)}}}hr(e){let t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:A(t[0],`
+`)}}blockquote(e){let t=this.rules.block.blockquote.exec(e);if(t){let n=A(t[0],`
+`).split(`
+`),s="",i="",r=[];for(;n.length>0;){let o=!1,a=[],c;for(c=0;c1,i={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");let r=this.rules.other.listItemRegex(n),o=!1;for(;e;){let c=!1,p="",u="";if(!(t=r.exec(e))||this.rules.block.hr.test(e))break;p=t[0],e=e.substring(p.length);let d=t[2].split(`
+`,1)[0].replace(this.rules.other.listReplaceTabs,Z=>" ".repeat(3*Z.length)),g=e.split(`
+`,1)[0],T=!d.trim(),f=0;if(this.options.pedantic?(f=2,u=d.trimStart()):T?f=t[1].length+1:(f=t[2].search(this.rules.other.nonSpaceChar),f=f>4?1:f,u=d.slice(f),f+=t[1].length),T&&this.rules.other.blankLine.test(g)&&(p+=g+`
+`,e=e.substring(g.length+1),c=!0),!c){let Z=this.rules.other.nextBulletRegex(f),te=this.rules.other.hrRegex(f),ne=this.rules.other.fencesBeginRegex(f),se=this.rules.other.headingBeginRegex(f),xe=this.rules.other.htmlBeginRegex(f);for(;e;){let G=e.split(`
+`,1)[0],C;if(g=G,this.options.pedantic?(g=g.replace(this.rules.other.listReplaceNesting," "),C=g):C=g.replace(this.rules.other.tabCharGlobal," "),ne.test(g)||se.test(g)||xe.test(g)||Z.test(g)||te.test(g))break;if(C.search(this.rules.other.nonSpaceChar)>=f||!g.trim())u+=`
+`+C.slice(f);else{if(T||d.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||ne.test(d)||se.test(d)||te.test(d))break;u+=`
+`+g}!T&&!g.trim()&&(T=!0),p+=G+`
+`,e=e.substring(G.length+1),d=C.slice(f)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(p)&&(o=!0));let y=null,ee;this.options.gfm&&(y=this.rules.other.listIsTask.exec(u),y&&(ee=y[0]!=="[ ] ",u=u.replace(this.rules.other.listReplaceTask,""))),i.items.push({type:"list_item",raw:p,task:!!y,checked:ee,loose:!1,text:u,tokens:[]}),i.raw+=p}let a=i.items.at(-1);if(a)a.raw=a.raw.trimEnd(),a.text=a.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let c=0;cd.type==="space"),u=p.length>0&&p.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=u}if(i.loose)for(let c=0;c({text:a,tokens:this.lexer.inline(a),header:!1,align:r.align[c]})));return r}}lheading(e){let t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:t[2].charAt(0)==="="?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){let t=this.rules.block.paragraph.exec(e);if(t){let n=t[1].charAt(t[1].length-1)===`
+`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:n,tokens:this.lexer.inline(n)}}}text(e){let t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){let t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(e){let t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){let t=this.rules.inline.link.exec(e);if(t){let n=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(n)){if(!this.rules.other.endAngleBracket.test(n))return;let r=A(n.slice(0,-1),"\\");if((n.length-r.length)%2===0)return}else{let r=de(t[2],"()");if(r===-2)return;if(r>-1){let a=(t[0].indexOf("!")===0?5:4)+t[1].length+r;t[2]=t[2].substring(0,r),t[0]=t[0].substring(0,a).trim(),t[3]=""}}let s=t[2],i="";if(this.options.pedantic){let r=this.rules.other.pedanticHrefTitle.exec(s);r&&(s=r[1],i=r[3])}else i=t[3]?t[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(n)?s=s.slice(1):s=s.slice(1,-1)),me(t,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){let s=(n[2]||n[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[s.toLowerCase()];if(!i){let r=n[0].charAt(0);return{type:"text",raw:r,text:r}}return me(n,i,n[0],this.lexer,this.rules)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s||s[3]&&n.match(this.rules.other.unicodeAlphaNumeric))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){let r=[...s[0]].length-1,o,a,c=r,p=0,u=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*e.length+r);(s=u.exec(t))!=null;){if(o=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!o)continue;if(a=[...o].length,s[3]||s[4]){c+=a;continue}else if((s[5]||s[6])&&r%3&&!((r+a)%3)){p+=a;continue}if(c-=a,c>0)continue;a=Math.min(a,a+c+p);let d=[...s[0]][0].length,g=e.slice(0,r+s.index+d+a);if(Math.min(r,a)%2){let f=g.slice(1,-1);return{type:"em",raw:g,text:f,tokens:this.lexer.inlineTokens(f)}}let T=g.slice(2,-2);return{type:"strong",raw:g,text:T,tokens:this.lexer.inlineTokens(T)}}}}codespan(e){let t=this.rules.inline.code.exec(e);if(t){let n=t[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(n),i=this.rules.other.startingSpaceChar.test(n)&&this.rules.other.endingSpaceChar.test(n);return s&&i&&(n=n.substring(1,n.length-1)),{type:"codespan",raw:t[0],text:n}}}br(e){let t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){let t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){let t=this.rules.inline.autolink.exec(e);if(t){let n,s;return t[2]==="@"?(n=t[1],s="mailto:"+n):(n=t[1],s=n),{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let n,s;if(t[2]==="@")n=t[0],s="mailto:"+n;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);n=t[0],t[1]==="www."?s="http://"+t[0]:s=t[0]}return{type:"link",raw:t[0],text:n,href:s,tokens:[{type:"text",raw:n,text:n}]}}}inlineText(e){let t=this.rules.inline.text.exec(e);if(t){let n=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:n}}}};var x=class l{tokens;options;state;tokenizer;inlineQueue;constructor(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||w,this.options.tokenizer=this.options.tokenizer||new S,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:m,block:B.normal,inline:P.normal};this.options.pedantic?(t.block=B.pedantic,t.inline=P.pedantic):this.options.gfm&&(t.block=B.gfm,this.options.breaks?t.inline=P.breaks:t.inline=P.gfm),this.tokenizer.rules=t}static get rules(){return{block:B,inline:P}}static lex(e,t){return new l(t).lex(e)}static lexInline(e,t){return new l(t).inlineTokens(e)}lex(e){e=e.replace(m.carriageReturn,`
+`),this.blockTokens(e,this.tokens);for(let t=0;t(s=r.call({lexer:this},e,t))?(e=e.substring(s.raw.length),t.push(s),!0):!1))continue;if(s=this.tokenizer.space(e)){e=e.substring(s.raw.length);let r=t.at(-1);s.raw.length===1&&r!==void 0?r.raw+=`
+`:t.push(s);continue}if(s=this.tokenizer.code(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(s=this.tokenizer.fences(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.heading(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.hr(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.blockquote(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.list(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.html(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.def(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="paragraph"||r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.raw,this.inlineQueue.at(-1).src=r.text):this.tokens.links[s.tag]||(this.tokens.links[s.tag]={href:s.href,title:s.title});continue}if(s=this.tokenizer.table(e)){e=e.substring(s.raw.length),t.push(s);continue}if(s=this.tokenizer.lheading(e)){e=e.substring(s.raw.length),t.push(s);continue}let i=e;if(this.options.extensions?.startBlock){let r=1/0,o=e.slice(1),a;this.options.extensions.startBlock.forEach(c=>{a=c.call({lexer:this},o),typeof a=="number"&&a>=0&&(r=Math.min(r,a))}),r<1/0&&r>=0&&(i=e.substring(0,r+1))}if(this.state.top&&(s=this.tokenizer.paragraph(i))){let r=t.at(-1);n&&r?.type==="paragraph"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s),n=i.length!==e.length,e=e.substring(s.raw.length);continue}if(s=this.tokenizer.text(e)){e=e.substring(s.raw.length);let r=t.at(-1);r?.type==="text"?(r.raw+=`
+`+s.raw,r.text+=`
+`+s.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=r.text):t.push(s);continue}if(e){let r="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(r);break}else throw new Error(r)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n=e,s=null;if(this.tokens.links){let o=Object.keys(this.tokens.links);if(o.length>0)for(;(s=this.tokenizer.rules.inline.reflinkSearch.exec(n))!=null;)o.includes(s[0].slice(s[0].lastIndexOf("[")+1,-1))&&(n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(s=this.tokenizer.rules.inline.anyPunctuation.exec(n))!=null;)n=n.slice(0,s.index)+"++"+n.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;(s=this.tokenizer.rules.inline.blockSkip.exec(n))!=null;)n=n.slice(0,s.index)+"["+"a".repeat(s[0].length-2)+"]"+n.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);let i=!1,r="";for(;e;){i||(r=""),i=!1;let o;if(this.options.extensions?.inline?.some(c=>(o=c.call({lexer:this},e,t))?(e=e.substring(o.raw.length),t.push(o),!0):!1))continue;if(o=this.tokenizer.escape(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.tag(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.link(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(o.raw.length);let c=t.at(-1);o.type==="text"&&c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(o=this.tokenizer.emStrong(e,n,r)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.codespan(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.br(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.del(e)){e=e.substring(o.raw.length),t.push(o);continue}if(o=this.tokenizer.autolink(e)){e=e.substring(o.raw.length),t.push(o);continue}if(!this.state.inLink&&(o=this.tokenizer.url(e))){e=e.substring(o.raw.length),t.push(o);continue}let a=e;if(this.options.extensions?.startInline){let c=1/0,p=e.slice(1),u;this.options.extensions.startInline.forEach(d=>{u=d.call({lexer:this},p),typeof u=="number"&&u>=0&&(c=Math.min(c,u))}),c<1/0&&c>=0&&(a=e.substring(0,c+1))}if(o=this.tokenizer.inlineText(a)){e=e.substring(o.raw.length),o.raw.slice(-1)!=="_"&&(r=o.raw.slice(-1)),i=!0;let c=t.at(-1);c?.type==="text"?(c.raw+=o.raw,c.text+=o.text):t.push(o);continue}if(e){let c="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(c);break}else throw new Error(c)}}return t}};var $=class{options;parser;constructor(e){this.options=e||w}space(e){return""}code({text:e,lang:t,escaped:n}){let s=(t||"").match(m.notSpaceStart)?.[0],i=e.replace(m.endingNewline,"")+`
+`;return s?''+(n?i:R(i,!0))+`
+`:""+(n?i:R(i,!0))+`
+`}blockquote({tokens:e}){return`
+${this.parser.parse(e)}
+`}html({text:e}){return e}heading({tokens:e,depth:t}){return`${this.parser.parseInline(e)}
+`}hr(e){return`
+`}list(e){let t=e.ordered,n=e.start,s="";for(let o=0;o
+`+s+""+i+`>
+`}listitem(e){let t="";if(e.task){let n=this.checkbox({checked:!!e.checked});e.loose?e.tokens[0]?.type==="paragraph"?(e.tokens[0].text=n+" "+e.tokens[0].text,e.tokens[0].tokens&&e.tokens[0].tokens.length>0&&e.tokens[0].tokens[0].type==="text"&&(e.tokens[0].tokens[0].text=n+" "+R(e.tokens[0].tokens[0].text),e.tokens[0].tokens[0].escaped=!0)):e.tokens.unshift({type:"text",raw:n+" ",text:n+" ",escaped:!0}):t+=n+" "}return t+=this.parser.parse(e.tokens,!!e.loose),`${t}
+`}checkbox({checked:e}){return" '}paragraph({tokens:e}){return`${this.parser.parseInline(e)}
+`}table(e){let t="",n="";for(let i=0;i${s}`),`
+`}tablerow({text:e}){return`
+${e}
+`}tablecell(e){let t=this.parser.parseInline(e.tokens),n=e.header?"th":"td";return(e.align?`<${n} align="${e.align}">`:`<${n}>`)+t+`${n}>
+`}strong({tokens:e}){return`${this.parser.parseInline(e)} `}em({tokens:e}){return`${this.parser.parseInline(e)} `}codespan({text:e}){return`${R(e,!0)}`}br(e){return" "}del({tokens:e}){return`${this.parser.parseInline(e)}`}link({href:e,title:t,tokens:n}){let s=this.parser.parseInline(n),i=V(e);if(i===null)return s;e=i;let r='"+s+" ",r}image({href:e,title:t,text:n,tokens:s}){s&&(n=this.parser.parseInline(s,this.parser.textRenderer));let i=V(e);if(i===null)return R(n);e=i;let r=` ",r}text(e){return"tokens"in e&&e.tokens?this.parser.parseInline(e.tokens):"escaped"in e&&e.escaped?e.text:R(e.text)}};var _=class{strong({text:e}){return e}em({text:e}){return e}codespan({text:e}){return e}del({text:e}){return e}html({text:e}){return e}text({text:e}){return e}link({text:e}){return""+e}image({text:e}){return""+e}br(){return""}};var b=class l{options;renderer;textRenderer;constructor(e){this.options=e||w,this.options.renderer=this.options.renderer||new $,this.renderer=this.options.renderer,this.renderer.options=this.options,this.renderer.parser=this,this.textRenderer=new _}static parse(e,t){return new l(t).parse(e)}static parseInline(e,t){return new l(t).parseInline(e)}parse(e,t=!0){let n="";for(let s=0;s{let o=i[r].flat(1/0);n=n.concat(this.walkTokens(o,t))}):i.tokens&&(n=n.concat(this.walkTokens(i.tokens,t)))}}return n}use(...e){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach(n=>{let s={...n};if(s.async=this.defaults.async||s.async||!1,n.extensions&&(n.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let r=t.renderers[i.name];r?t.renderers[i.name]=function(...o){let a=i.renderer.apply(this,o);return a===!1&&(a=r.apply(this,o)),a}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let r=t[i.level];r?r.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),s.extensions=t),n.renderer){let i=this.defaults.renderer||new $(this.defaults);for(let r in n.renderer){if(!(r in i))throw new Error(`renderer '${r}' does not exist`);if(["options","parser"].includes(r))continue;let o=r,a=n.renderer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u||""}}s.renderer=i}if(n.tokenizer){let i=this.defaults.tokenizer||new S(this.defaults);for(let r in n.tokenizer){if(!(r in i))throw new Error(`tokenizer '${r}' does not exist`);if(["options","rules","lexer"].includes(r))continue;let o=r,a=n.tokenizer[o],c=i[o];i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.tokenizer=i}if(n.hooks){let i=this.defaults.hooks||new L;for(let r in n.hooks){if(!(r in i))throw new Error(`hook '${r}' does not exist`);if(["options","block"].includes(r))continue;let o=r,a=n.hooks[o],c=i[o];L.passThroughHooks.has(r)?i[o]=p=>{if(this.defaults.async)return Promise.resolve(a.call(i,p)).then(d=>c.call(i,d));let u=a.call(i,p);return c.call(i,u)}:i[o]=(...p)=>{let u=a.apply(i,p);return u===!1&&(u=c.apply(i,p)),u}}s.hooks=i}if(n.walkTokens){let i=this.defaults.walkTokens,r=n.walkTokens;s.walkTokens=function(o){let a=[];return a.push(r.call(this,o)),i&&(a=a.concat(i.call(this,o))),a}}this.defaults={...this.defaults,...s}}),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return x.lex(e,t??this.defaults)}parser(e,t){return b.parse(e,t??this.defaults)}parseMarkdown(e){return(n,s)=>{let i={...s},r={...this.defaults,...i},o=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&i.async===!1)return o(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof n>"u"||n===null)return o(new Error("marked(): input parameter is undefined or null"));if(typeof n!="string")return o(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));r.hooks&&(r.hooks.options=r,r.hooks.block=e);let a=r.hooks?r.hooks.provideLexer():e?x.lex:x.lexInline,c=r.hooks?r.hooks.provideParser():e?b.parse:b.parseInline;if(r.async)return Promise.resolve(r.hooks?r.hooks.preprocess(n):n).then(p=>a(p,r)).then(p=>r.hooks?r.hooks.processAllTokens(p):p).then(p=>r.walkTokens?Promise.all(this.walkTokens(p,r.walkTokens)).then(()=>p):p).then(p=>c(p,r)).then(p=>r.hooks?r.hooks.postprocess(p):p).catch(o);try{r.hooks&&(n=r.hooks.preprocess(n));let p=a(n,r);r.hooks&&(p=r.hooks.processAllTokens(p)),r.walkTokens&&this.walkTokens(p,r.walkTokens);let u=c(p,r);return r.hooks&&(u=r.hooks.postprocess(u)),u}catch(p){return o(p)}}}onError(e,t){return n=>{if(n.message+=`
+Please report this to https://github.com/markedjs/marked.`,e){let s="An error occurred:
"+R(n.message+"",!0)+" ";return t?Promise.resolve(s):s}if(t)return Promise.reject(n);throw n}}};var M=new E;function k(l,e){return M.parse(l,e)}k.options=k.setOptions=function(l){return M.setOptions(l),k.defaults=M.defaults,N(k.defaults),k};k.getDefaults=z;k.defaults=w;k.use=function(...l){return M.use(...l),k.defaults=M.defaults,N(k.defaults),k};k.walkTokens=function(l,e){return M.walkTokens(l,e)};k.parseInline=M.parseInline;k.Parser=b;k.parser=b.parse;k.Renderer=$;k.TextRenderer=_;k.Lexer=x;k.lexer=x.lex;k.Tokenizer=S;k.Hooks=L;k.parse=k;var it=k.options,ot=k.setOptions,lt=k.use,at=k.walkTokens,ct=k.parseInline,pt=k,ut=b.parse,ht=x.lex;
+
+if(__exports != exports)module.exports = exports;return module.exports}));
diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs
new file mode 100644
index 0000000..ff5b024
--- /dev/null
+++ b/src/inc/routes/comments.mjs
@@ -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\/(?\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\/(?\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\/(?\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;
+};
diff --git a/src/inc/routes/emojis.mjs b/src/inc/routes/emojis.mjs
new file mode 100644
index 0000000..7576d91
--- /dev/null
+++ b/src/inc/routes/emojis.mjs
@@ -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\/(?\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;
+};
diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs
new file mode 100644
index 0000000..cfdccf7
--- /dev/null
+++ b/src/inc/routes/notifications.mjs
@@ -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\/(?\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;
+};
diff --git a/views/admin.html b/views/admin.html
index 60486c6..023d434 100644
--- a/views/admin.html
+++ b/views/admin.html
@@ -17,6 +17,7 @@
Approval Queue
Sessions
Invite Tokens
+ Emoji Manager
diff --git a/views/admin/emojis.html b/views/admin/emojis.html
new file mode 100644
index 0000000..7f1008b
--- /dev/null
+++ b/views/admin/emojis.html
@@ -0,0 +1,96 @@
+@include(snippets/header)
+
+
+
+
+
+@include(snippets/footer)
\ No newline at end of file
diff --git a/views/item-partial.html b/views/item-partial.html
index ee73aac..706039b 100644
--- a/views/item-partial.html
+++ b/views/item-partial.html
@@ -95,10 +95,12 @@
{{ item.id }}
@if(session)
- ({{ user.name }} )
+ ({{user.name }} )
@endif
- {{ item.timestamp.timeago }}
+ {{item.timestamp.timeago }}
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
@@ -121,6 +123,6 @@
style="height: 32px; width: 32px" />
@endeach
@endif
-
-
\ No newline at end of file
+
+
\ No newline at end of file
diff --git a/views/snippets/footer.html b/views/snippets/footer.html
index b4f08b1..e72c23e 100644
--- a/views/snippets/footer.html
+++ b/views/snippets/footer.html
@@ -22,6 +22,7 @@
+
@if(session && session.admin)
@elseif(session && !session.admin)
diff --git a/views/snippets/header.html b/views/snippets/header.html
index e948558..28cee05 100644
--- a/views/snippets/header.html
+++ b/views/snippets/header.html
@@ -7,6 +7,8 @@
+ @if(typeof item !== 'undefined')
+ @endif
@if(typeof item !== 'undefined')
diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html
index d4eec19..8d41543 100644
--- a/views/snippets/navbar.html
+++ b/views/snippets/navbar.html
@@ -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" />
@endif
+
Comments (${comments.length})
+