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 `${name}`; + } + 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 = ` +
+

Comments (${comments.length})

+
+ + ${currentUserId ? `` : ''} + +
+
+ ${currentUserId ? this.renderInput() : '
Login to comment
'} +
+ ${roots.map(c => this.renderComment(c, currentUserId)).join('')} +
+ `; + + 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 ` +
+
+ av +
+
+
+ ${comment.username || 'System'} + ${date} + #${comment.id} + ${!isDeleted && currentUserId ? `` : ''} +
+
${content}
+ ${comment.children.length > 0 ? `
${comment.children.map(c => this.renderComment(c, currentUserId)).join('')}
` : ''} +
+
+ `; + } + + escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + renderInput(parentId = null) { + return ` +
+ +
+ + ${parentId ? '' : ''} +
+
+ `; + } + + 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:/^$/,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]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\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",")|<(?: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",")|<(?: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",")|<(?:script|pre|style|textarea|!--)").replace("tag",v).getRegex()},Be={...X,html:h(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\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:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\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-]*(?: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+" +`}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}`),` + +`+t+` +`+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+` +`}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='
    ",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=`${n}{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) + +
    +

    Custom Emojis

    + +
    +

    Add New Emoji

    + + + +
    + +
    + + + + + + + + + + + + +
    PreviewNameURLActions
    +
    +
    + + + +@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 - + @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 +