simple-message-board
Version:
简易留言板应用,支持markdown格式
1,214 lines (1,073 loc) • 45.5 kB
JavaScript
const LANGUAGE_KEY = 'lang';
let currentLanguage = 'zh';
const HTML_PARAM_KEYS = new Set(['term']);
const LANGUAGE_OPTIONS = {
zh: { label: '中文', locale: 'zh-CN' },
en: { label: 'English', locale: 'en' }
};
const translations = {
zh: {
headerTitle: '简易留言板',
headerSubtitle: function ({ max }) { return '支持 Markdown 留言,按 Ctrl + Enter 快速提交。最多保留 ' + max + ' 条。'; },
statsTotal: function ({ total }) { return '共 ' + total + ' 条留言'; },
statsMatches: function ({ total }) { return '共 ' + total + ' 条匹配'; },
submitButton: '提交留言',
toolbarHeading1: 'H1',
toolbarHeading2: 'H2',
toolbarBold: 'B',
toolbarItalic: 'I',
toolbarListUl: '• 列表',
toolbarListOl: '1. 列表',
toolbarInlineCode: '内联代码',
toolbarCodeBlock: '代码块',
toolbarQuote: '引用',
toolbarLink: '链接',
textareaPlaceholder: '试试使用 **Markdown** 语法,支持代码块、列表等格式。',
tagsPlaceholder: '添加标签(用逗号或空格分隔)',
searchTitle: '搜索留言',
searchSubtitle: '支持模糊匹配并保留分页',
searchButton: '搜索',
searchClear: '清除',
searchPlaceholder: '输入关键字',
searchFilter: function ({ term }) { return '已筛选:' + term; },
languageZh: '中文',
languageEn: 'English',
themeLight: '亮色',
themeDark: '暗色',
paginationLabel: function ({ current, totalpages }) { return '第 ' + current + ' / ' + totalpages + ' 页'; },
paginationPrev: '上一页',
paginationNext: '下一页',
emptyDefault: '还没有留言,快来留下第一条消息吧~',
emptySearch: function ({ term }) { return '没有找到包含 "' + term + '" 的留言。'; },
copyButton: '复制',
copySuccess: '已复制',
copyFailure: '复制失败',
deleteButton: '删除',
codeFallback: '代码',
replyButton: '添加答复',
replyPlaceholder: '输入答复内容...',
replySubmit: '发送',
replyCancel: '取消'
},
en: {
headerTitle: 'Simple Message Board',
headerSubtitle: function ({ max }) { return 'Supports Markdown posts. Press Ctrl + Enter to submit. Keeps up to ' + max + ' entries.'; },
statsTotal: function ({ total }) { return 'Total ' + total + ' messages'; },
statsMatches: function ({ total }) { return total + ' result' + (total === 1 ? '' : 's') + ' found'; },
submitButton: 'Submit Message',
toolbarHeading1: 'H1',
toolbarHeading2: 'H2',
toolbarBold: 'Bold',
toolbarItalic: 'Italic',
toolbarListUl: '• List',
toolbarListOl: '1. List',
toolbarInlineCode: 'Inline Code',
toolbarCodeBlock: 'Code Block',
toolbarQuote: 'Quote',
toolbarLink: 'Link',
textareaPlaceholder: 'Try **Markdown** syntax — code blocks, lists, etc.',
tagsPlaceholder: 'Add tags (comma or space separated)',
searchTitle: 'Search Messages',
searchSubtitle: 'Supports fuzzy matching and keeps pagination',
searchButton: 'Search',
searchClear: 'Clear',
searchPlaceholder: 'Enter keywords',
searchFilter: function ({ term }) { return 'Filter: ' + term; },
languageZh: 'Chinese',
languageEn: 'English',
themeLight: 'Light',
themeDark: 'Dark',
paginationLabel: function ({ current, totalpages }) { return 'Page ' + current + ' / ' + totalpages; },
paginationPrev: 'Previous',
paginationNext: 'Next',
emptyDefault: 'No messages yet — be the first!',
emptySearch: function ({ term }) { return 'No messages found containing "' + term + '".'; },
copyButton: 'Copy',
copySuccess: 'Copied',
copyFailure: 'Copy failed',
deleteButton: 'Delete',
codeFallback: 'Code',
replyButton: 'Add Reply',
replyPlaceholder: 'Enter your reply...',
replySubmit: 'Send',
replyCancel: 'Cancel'
}
};
function decodeEntities(value = '') {
const textarea = document.createElement('textarea');
textarea.innerHTML = value;
return textarea.value;
}
function getParams(el) {
const params = {};
for (const [name, raw] of Object.entries(el.dataset)) {
if (name.startsWith('i18n')) {
continue;
}
let value = raw;
if (HTML_PARAM_KEYS.has(name)) {
value = decodeEntities(value);
}
const numeric = Number(value);
params[name] = Number.isFinite(numeric) && value !== '' ? numeric : value;
}
return params;
}
function t(key, vars = {}, lang = currentLanguage) {
const dict = translations[lang] || translations.zh;
const value = dict[key] ?? translations.zh[key];
if (typeof value === 'function') {
return value(vars);
}
return value !== undefined ? value : key;
}
function applyLanguage(mode) {
currentLanguage = mode;
const languageOption = LANGUAGE_OPTIONS[mode] || LANGUAGE_OPTIONS.zh;
document.documentElement.setAttribute('lang', languageOption.locale);
const themeMode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
document.querySelectorAll('[data-i18n]').forEach((element) => {
const key = element.dataset.i18n;
if (!key) return;
const params = getParams(element);
let value = t(key, params, mode);
if (element.dataset.uppercase === 'true' && typeof value === 'string') {
value = value.toUpperCase();
}
// data-i18n should only be used on text-only elements
// For complex structures with icons, wrap the text in a separate span with data-i18n
element.textContent = value;
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((element) => {
const key = element.dataset.i18nPlaceholder;
if (!key) return;
const params = getParams(element);
element.setAttribute('placeholder', t(key, params, mode));
});
document.querySelectorAll('[data-i18n-title]').forEach((element) => {
const key = element.dataset.i18nTitle;
if (!key) return;
const params = getParams(element);
const value = t(key, params, mode);
element.setAttribute('title', value);
element.setAttribute('aria-label', value);
});
document.title = t('headerTitle', {}, mode);
updateThemeToggle(themeMode);
updateLanguageToggle(mode);
}
function initializeLanguage() {
const toggle = document.getElementById('language-toggle');
let stored = null;
try {
stored = localStorage.getItem(LANGUAGE_KEY);
} catch (error) {
stored = null;
}
const initial = stored && LANGUAGE_OPTIONS[stored] ? stored : 'zh';
applyLanguage(initial);
if (stored !== initial) {
persistLanguage(initial);
}
toggle?.addEventListener('click', () => {
const next = currentLanguage === 'zh' ? 'en' : 'zh';
persistLanguage(next);
applyLanguage(next);
});
}
function updateLanguageToggle(mode) {
const toggle = document.getElementById('language-toggle');
if (!toggle) return;
const label = toggle.querySelector('.language-toggle-label');
const option = LANGUAGE_OPTIONS[mode] || LANGUAGE_OPTIONS.zh;
if (label) {
label.textContent = option.label;
}
}
function persistLanguage(value) {
try {
localStorage.setItem(LANGUAGE_KEY, value);
} catch (error) {
// ignore
}
}
function initializeTheme() {
const root = document.documentElement;
const themeToggle = document.getElementById('theme-toggle');
const media = window.matchMedia('(prefers-color-scheme: dark)');
let stored = null;
try {
stored = localStorage.getItem('theme');
} catch (error) {
stored = null;
}
const preferred = media.matches ? 'dark' : 'light';
const initial = stored === 'dark' || stored === 'light' ? stored : preferred;
applyTheme(initial);
if (!stored) {
persistTheme(initial);
}
themeToggle?.addEventListener('click', () => {
const nextTheme = root.classList.contains('dark') ? 'light' : 'dark';
persistTheme(nextTheme);
applyTheme(nextTheme);
});
media.addEventListener('change', (event) => {
let saved = null;
try {
saved = localStorage.getItem('theme');
} catch (error) {
saved = null;
}
if (saved === 'light' || saved === 'dark') {
return;
}
applyTheme(event.matches ? 'dark' : 'light');
});
}
function applyTheme(mode) {
const root = document.documentElement;
root.classList.toggle('dark', mode === 'dark');
updateThemeToggle(mode);
}
function updateThemeToggle(mode) {
const button = document.getElementById('theme-toggle');
if (!button) return;
const icon = button.querySelector('span[aria-hidden="true"]');
const label = button.querySelector('.theme-toggle-label');
if (icon) {
icon.textContent = mode === 'dark' ? '🌙' : '☀️';
}
if (label) {
label.textContent = mode === 'dark' ? t('themeDark') : t('themeLight');
}
}
function persistTheme(value) {
try {
localStorage.setItem('theme', value);
} catch (error) {
// ignore storage errors
}
}
function applyMarkdown(textarea, action) {
if (!action) return;
textarea.focus();
let start = textarea.selectionStart;
let end = textarea.selectionEnd;
if (start === null || start === undefined || Number.isNaN(start)) {
start = textarea.value.length;
}
if (end === null || end === undefined || Number.isNaN(end)) {
end = start;
}
const value = textarea.value;
const selected = value.slice(start, end);
let replacement = selected;
let innerStart = 0;
let innerEnd = replacement.length;
const tick = String.fromCharCode(96);
const fence = tick.repeat(3);
const selectAll = function () {
innerStart = 0;
innerEnd = replacement.length;
};
switch (action) {
case 'heading-1': {
const text = selected || '标题';
replacement = '# ' + text;
innerStart = 2;
innerEnd = innerStart + text.length;
break;
}
case 'heading-2': {
const text = selected || '小标题';
replacement = '## ' + text;
innerStart = 3;
innerEnd = innerStart + text.length;
break;
}
case 'bold': {
const text = selected || '文本';
replacement = '**' + text + '**';
innerStart = 2;
innerEnd = innerStart + text.length;
break;
}
case 'italic': {
const text = selected || '文本';
replacement = '*' + text + '*';
innerStart = 1;
innerEnd = innerStart + text.length;
break;
}
case 'list-ul': {
const source = selected || '列表项';
const lines = source.split(/\r?\n/);
replacement = lines.map((line) => '- ' + (line || '列表项')).join('\n');
selectAll();
break;
}
case 'list-ol': {
const source = selected || '列表项';
const lines = source.split(/\r?\n/);
replacement = lines.map((line, index) => (index + 1) + '. ' + (line || '列表项')).join('\n');
selectAll();
break;
}
case 'code': {
const text = selected || '代码';
replacement = tick + text + tick;
innerStart = 1;
innerEnd = innerStart + text.length;
break;
}
case 'code-block': {
const text = selected || '代码';
replacement = fence + '\n' + text + '\n' + fence + '\n';
innerStart = fence.length + 1;
innerEnd = innerStart + text.length;
break;
}
case 'quote': {
const source = selected || '引用内容';
const lines = source.split(/\r?\n/);
replacement = lines.map((line) => '> ' + (line || '引用内容')).join('\n');
selectAll();
break;
}
case 'link': {
const text = selected || '链接文本';
replacement = '[' + text + '](https://example.com)';
innerStart = 1;
innerEnd = innerStart + text.length;
break;
}
default:
return;
}
const before = value.slice(0, start);
const after = value.slice(end);
textarea.value = before + replacement + after;
const offset = before.length;
textarea.setSelectionRange(offset + innerStart, offset + innerEnd);
textarea.focus();
textarea.dispatchEvent(new Event('input'));
}
function initializeMarkdownRendering() {
if (window.marked) {
marked.setOptions({
gfm: true,
breaks: true,
smartypants: true,
highlight: (code, language) => {
if (window.hljs) {
if (language && hljs.getLanguage(language)) {
return hljs.highlight(code, { language }).value;
}
return hljs.highlightAuto(code).value;
}
return code;
}
});
}
const blocks = document.querySelectorAll('[data-markdown]');
blocks.forEach((element) => {
const markdownText = element.getAttribute('data-markdown') || '';
if (window.marked) {
const rawHtml = marked.parse(markdownText);
const safeHtml = window.DOMPurify ? DOMPurify.sanitize(rawHtml) : rawHtml;
element.innerHTML = safeHtml;
} else {
element.textContent = markdownText;
}
});
if (window.hljs) {
const codeBlocks = document.querySelectorAll('.message-content pre code');
codeBlocks.forEach((block) => window.hljs.highlightElement(block));
}
enhanceCodeBlocks();
// 应用搜索高亮
applySearchHighlight();
}
function fallbackCopy(text, onComplete) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'absolute';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
onComplete('copySuccess');
} catch (error) {
onComplete('copyFailure');
} finally {
document.body.removeChild(textarea);
}
}
function wrapCodeBlock(pre) {
if (pre.dataset.enhanced === 'true') {
return;
}
const codeElement = pre.querySelector('code') || pre;
if (!codeElement) {
return;
}
pre.dataset.enhanced = 'true';
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper group overflow-hidden rounded-xl border border-border bg-muted/30 text-foreground shadow-sm backdrop-blur';
const header = document.createElement('div');
header.className = 'flex items-center justify-between border-b border-border/70 bg-muted/50 px-4 py-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground';
const title = document.createElement('span');
const languageMatch = (codeElement.className || '').match(/language-([\w-]+)/i);
if (languageMatch && languageMatch[1]) {
title.textContent = languageMatch[1].toUpperCase();
} else {
title.dataset.i18n = 'codeFallback';
title.dataset.uppercase = 'true';
title.textContent = t('codeFallback').toUpperCase();
}
header.appendChild(title);
const actions = document.createElement('div');
actions.className = 'flex items-center gap-2';
const copyButton = document.createElement('button');
copyButton.type = 'button';
copyButton.className = 'inline-flex items-center gap-1 rounded-md border border-input bg-background px-2.5 py-1 text-[11px] font-medium tracking-wide text-foreground shadow-sm transition hover:bg-accent hover:text-accent-foreground focus-visible:outline focus-visible:ring-2 focus-visible:ring-ring';
copyButton.dataset.i18n = 'copyButton';
copyButton.textContent = t('copyButton');
copyButton.addEventListener('click', () => {
const originalText = codeElement.innerText;
const finish = (messageKey) => {
const type = messageKey === 'copySuccess' ? 'success' : 'error';
showToast(t(messageKey), type);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(originalText).then(() => {
finish('copySuccess');
}).catch(() => {
fallbackCopy(originalText, finish);
});
} else {
fallbackCopy(originalText, finish);
}
});
actions.appendChild(copyButton);
header.appendChild(actions);
const body = document.createElement('div');
body.className = 'relative bg-background/80 transition-colors';
pre.classList.add('m-0', 'max-h-[60vh]', 'overflow-auto', 'bg-transparent', 'p-4', 'text-sm', 'leading-6');
const parent = pre.parentNode;
if (parent) {
wrapper.appendChild(header);
parent.replaceChild(wrapper, pre);
body.appendChild(pre);
wrapper.appendChild(body);
}
}
function showToast(message, type = 'default') {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed bottom-6 right-6 z-50 flex flex-col gap-2 pointer-events-none';
document.body.appendChild(container);
}
const toast = document.createElement('div');
toast.className = 'pointer-events-auto flex items-center gap-2 rounded-lg border border-border bg-foreground px-4 py-2.5 text-sm font-medium text-background shadow-lg shadow-black/10 transition-all duration-300 translate-y-8 opacity-0';
// Icon based on type (optional, simpler to just use text for now or SVG)
let icon = '';
if (type === 'success') {
icon = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
}
toast.innerHTML = `${icon}<span>${message}</span>`;
container.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => {
toast.classList.remove('translate-y-8', 'opacity-0');
});
setTimeout(() => {
toast.classList.add('translate-y-4', 'opacity-0');
setTimeout(() => {
toast.remove();
}, 300);
}, 3000);
}
function enhanceCodeBlocks() {
const blocks = document.querySelectorAll('.message-content pre');
blocks.forEach(wrapCodeBlock);
}
function enhanceCodeBlockSingle(pre) {
wrapCodeBlock(pre);
}
function initializeAutoRefresh() {
const urlParams = new URLSearchParams(window.location.search);
const currentPage = parseInt(urlParams.get('page'), 10) || 1;
const searchQuery = urlParams.get('q') || '';
const tagFilter = urlParams.get('tag') || '';
// 禁用自动刷新:如果不在第一页,或有搜索/标签过滤
if (currentPage !== 1 || searchQuery.trim() !== '' || tagFilter.trim() !== '') {
return;
}
const messageList = document.querySelector('ul.space-y-4');
if (!messageList) {
return;
}
const existingMessages = messageList.querySelectorAll('li[data-message-id]');
let latestId = 0;
existingMessages.forEach((li) => {
const id = parseInt(li.dataset.messageId, 10);
if (!Number.isNaN(id) && id > latestId) {
latestId = id;
}
});
const POLL_INTERVAL = 5000;
const pageSize = Number(document.body?.dataset.pageSize) || 20;
const pollNewMessages = async () => {
try {
const response = await fetch('/api/messages?since_id=' + latestId + '&limit=' + pageSize);
if (!response.ok) {
return;
}
const data = await response.json();
if (!data.messages || data.messages.length === 0) {
return;
}
data.messages.forEach((msg) => {
if (msg.id > latestId) {
latestId = msg.id;
}
insertNewMessage(msg, messageList, pageSize);
});
updateStatsCounter(data.messages.length);
} catch (error) {
console.error('Failed to poll new messages:', error);
}
};
setInterval(pollNewMessages, POLL_INTERVAL);
}
function renderReplyItemClient(reply, messageId, currentPage, searchTerm, tagFilter) {
const safeMarkdown = escapeAttributeClient(reply.content);
const fallbackHtml = escapeHtmlClient(reply.content);
const displayTime = formatDisplayTimeClient(reply.created_at);
const searchHidden = searchTerm ? '<input type="hidden" name="q" value="' + escapeAttributeClient(searchTerm) + '">' : '';
const tagHidden = tagFilter ? '<input type="hidden" name="tag" value="' + escapeAttributeClient(tagFilter) + '">' : '';
return '<div class="reply-item group/item flex gap-3 py-3 first:pt-0 last:pb-0" data-reply-id="' + reply.id + '">' +
'<div class="flex-shrink-0 mt-1">' +
'<div class="w-6 h-6 rounded-full bg-muted flex items-center justify-center">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z"/></svg>' +
'</div>' +
'</div>' +
'<div class="flex-1 min-w-0">' +
'<p class="text-[10px] font-medium text-muted-foreground mb-1">' + displayTime + '</p>' +
'<div class="reply-content prose prose-slate max-w-none text-xs dark:prose-invert" data-markdown="' + safeMarkdown + '">' + fallbackHtml + '</div>' +
'</div>' +
'<form action="/delete-reply" method="post" class="flex-shrink-0 self-start opacity-0 group-hover/item:opacity-100 transition-opacity">' +
'<input type="hidden" name="id" value="' + reply.id + '">' +
'<input type="hidden" name="page" value="' + currentPage + '">' +
searchHidden +
tagHidden +
'<button type="submit" class="inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition" data-i18n-title="deleteButton">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>' +
'</button>' +
'</form>' +
'</div>';
}
function renderRepliesSectionClient(replies = [], messageId, currentPage, searchTerm, tagFilter) {
const repliesHtml = replies.length > 0
? '<div class="replies-list divide-y divide-border/50">' +
replies.map((reply) => renderReplyItemClient(reply, messageId, currentPage, searchTerm, tagFilter)).join('') +
'</div>'
: '';
const replyCount = replies.length > 0 ? '<span class="text-[10px] text-muted-foreground/70">(' + replies.length + ')</span>' : '';
const searchHidden = searchTerm ? '<input type="hidden" name="q" value="' + escapeAttributeClient(searchTerm) + '">' : '';
const tagHidden = tagFilter ? '<input type="hidden" name="tag" value="' + escapeAttributeClient(tagFilter) + '">' : '';
return '<div class="replies-section border-t border-border/50 mx-5 px-0 pb-5 pt-4">' +
repliesHtml +
'<div class="reply-form-container ' + (replies.length > 0 ? 'mt-3' : '') + '">' +
'<button type="button" class="reply-toggle-btn inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-primary transition" data-message-id="' + messageId + '">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m3 21 1.9-5.7a8.5 8.5 0 1 1 3.8 3.8z"/></svg>' +
'<span data-i18n="replyButton">' + t('replyButton') + '</span>' +
replyCount +
'</button>' +
'<form action="/reply" method="post" class="reply-form hidden mt-3" data-message-id="' + messageId + '">' +
'<input type="hidden" name="message_id" value="' + messageId + '">' +
'<input type="hidden" name="page" value="' + currentPage + '">' +
searchHidden +
tagHidden +
'<div class="flex gap-2">' +
'<textarea name="content" rows="2" required placeholder="' + t('replyPlaceholder') + '" class="flex-1 rounded-lg border border-input bg-background px-3 py-2 text-xs leading-5 text-foreground shadow-sm focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/40 resize-none" data-i18n-placeholder="replyPlaceholder"></textarea>' +
'<div class="flex flex-col gap-1">' +
'<button type="submit" class="inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground shadow transition hover:bg-primary/90">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>' +
'</button>' +
'<button type="button" class="reply-cancel-btn inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium text-muted-foreground shadow-sm transition hover:bg-accent hover:text-accent-foreground">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>' +
'</button>' +
'</div>' +
'</div>' +
'</form>' +
'</div>' +
'</div>';
}
function trimMessageList(listElement, pageSize) {
const maxItems = Number(pageSize) || Number(document.body?.dataset.pageSize) || 20;
const items = listElement.querySelectorAll('li[data-message-id]');
if (items.length <= maxItems) {
return;
}
const toRemove = Array.from(items).slice(maxItems);
toRemove.forEach((item) => item.remove());
}
function insertNewMessage(message, listElement, pageSize = Number(document.body?.dataset.pageSize) || 20) {
const existingItem = listElement.querySelector('li[data-message-id="' + message.id + '"]');
if (existingItem) {
return;
}
const emptyPlaceholder = listElement.querySelector('li[data-i18n="emptyDefault"]');
if (emptyPlaceholder) {
emptyPlaceholder.remove();
}
const safeMarkdown = escapeAttributeClient(message.content);
const fallbackHtml = escapeHtmlClient(message.content);
const displayTime = formatDisplayTimeClient(message.created_at);
const urlParams = new URLSearchParams(window.location.search);
const currentPage = parseInt(urlParams.get('page'), 10) || 1;
const searchTerm = getSearchTerm();
const tagFilter = urlParams.get('tag') || document.body?.dataset.tagFilter || '';
const searchHidden = searchTerm ? '<input type="hidden" name="q" value="' + escapeAttributeClient(searchTerm) + '">' : '';
const tagHidden = tagFilter ? '<input type="hidden" name="tag" value="' + escapeAttributeClient(tagFilter) + '">' : '';
// 渲染标签
let tagsHtml = '';
if (message.tags && message.tags.length > 0) {
tagsHtml = '<div class="message-tags flex flex-wrap gap-2 mt-3" data-all-tags=\'' + escapeAttributeClient(JSON.stringify(message.tags)) + '\'>';
message.tags.forEach(function(tag) {
tagsHtml += '<a href="/?tag=' + tag.id + '" ' +
'class="tag-item group inline-flex items-center gap-0.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-all hover:brightness-105 active:scale-95" ' +
'style="background-color: ' + tag.color + '10; color: ' + tag.color + '; border: 1px solid ' + tag.color + '20;" ' +
'data-usage-count="' + (tag.usage_count || 0) + '">' +
'<span class="opacity-50 transition-opacity group-hover:opacity-70">#</span>' +
escapeHtmlClient(tag.name) +
'</a>';
});
tagsHtml += '</div>';
}
const repliesSection = renderRepliesSectionClient(
Array.isArray(message.replies) ? message.replies : [],
message.id,
currentPage,
searchTerm,
tagFilter
);
const li = document.createElement('li');
li.className = 'group/reply rounded-xl border border-border bg-card text-card-foreground shadow-sm transition hover:-translate-y-[1px] hover:shadow-md';
li.dataset.messageId = message.id;
li.style.animation = 'slideIn 0.35s ease-out';
li.innerHTML = '<div class="flex flex-col gap-4 p-5 sm:flex-row sm:items-start sm:justify-between sm:gap-6">' +
'<div class="flex-1 min-w-0">' +
'<p class="text-xs font-medium text-muted-foreground mb-2">' + displayTime + '</p>' +
'<div class="message-content prose prose-slate max-w-none text-sm dark:prose-invert" data-markdown="' + safeMarkdown + '">' + fallbackHtml + '</div>' +
tagsHtml +
'</div>' +
'<form action="/delete" method="post" class="flex shrink-0 items-center justify-end sm:self-start">' +
'<input type="hidden" name="id" value="' + message.id + '">' +
'<input type="hidden" name="page" value="' + currentPage + '">' +
searchHidden +
tagHidden +
'<button type="submit" class="inline-flex h-9 items-center justify-center whitespace-nowrap rounded-md border border-destructive/40 bg-destructive/10 px-3 text-xs font-medium text-destructive shadow-sm transition hover:bg-destructive hover:text-destructive-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-i18n-title="deleteButton">' +
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-1.5"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>' +
'<span data-i18n="deleteButton">' + t('deleteButton') + '</span>' +
'</button>' +
'</form>' +
'</div>' +
repliesSection;
listElement.insertBefore(li, listElement.firstChild);
const contentElement = li.querySelector('.message-content');
if (contentElement && window.marked) {
const rawHtml = marked.parse(message.content);
const safeHtml = window.DOMPurify ? DOMPurify.sanitize(rawHtml) : rawHtml;
contentElement.innerHTML = safeHtml;
}
if (window.hljs) {
const codeBlocks = contentElement.querySelectorAll('pre code');
codeBlocks.forEach((block) => window.hljs.highlightElement(block));
}
const preElements = contentElement.querySelectorAll('pre');
preElements.forEach((pre) => {
if (pre.dataset.enhanced !== 'true') {
enhanceCodeBlockSingle(pre);
}
});
// 应用响应式标签显示
const tagsContainer = li.querySelector('.message-tags');
if (tagsContainer) {
applyResponsiveTags(tagsContainer);
}
initializeReplyForms(li);
trimMessageList(listElement, pageSize);
}
function escapeAttributeClient(value = '') {
return value
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\r?\n/g, ' ');
}
function escapeHtmlClient(value = '') {
return value
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/\r?\n/g, '<br>');
}
function formatDisplayTimeClient(isoString) {
const date = isoString ? new Date(isoString) : new Date();
if (Number.isNaN(date.getTime())) {
return new Date().toLocaleString('zh-CN', { hour12: false });
}
return date.toLocaleString('zh-CN', { hour12: false });
}
function updateStatsCounter(increment) {
const statsElement = document.querySelector('[data-i18n="statsTotal"]');
if (statsElement) {
const currentTotal = parseInt(statsElement.dataset.total, 10) || 0;
const newTotal = currentTotal + increment;
statsElement.dataset.total = newTotal;
statsElement.textContent = t('statsTotal', { total: newTotal });
}
}
/**
* 根据屏幕宽度计算可显示的标签数量
*/
function calculateMaxVisibleTags() {
const width = window.innerWidth;
if (width < 640) {
// Mobile: 窄屏最多显示 5 个标签
return 5;
} else if (width < 1024) {
// Tablet: 中等屏幕最多显示 8 个标签
return 8;
} else if (width < 1536) {
// Desktop: 大屏最多显示 12 个标签
return 12;
} else {
// Large Desktop: 超大屏最多显示 15 个标签
return 15;
}
}
/**
* 响应式显示标签 - 根据容器实际宽度和标签实际宽度动态计算
*/
function applyResponsiveTags(container) {
const tagItems = container.querySelectorAll('.tag-item');
if (tagItems.length === 0) {
return;
}
// 获取容器可用宽度
const containerWidth = container.offsetWidth;
if (containerWidth === 0) {
// 容器未渲染,稍后重试
return;
}
// 计算每个标签的宽度(包括margin)
let totalWidth = 0;
let maxVisible = 0;
const gap = 8; // gap-2 = 8px
const moreButtonWidth = 60; // "+N" 按钮的预估宽度
// 遍历标签,累加宽度直到超出容器
for (let i = 0; i < tagItems.length; i++) {
const tag = tagItems[i];
const tagWidth = tag.offsetWidth || tag.getBoundingClientRect().width;
// 检查是否是最后几个标签,如果是则需要预留"更多"按钮的空间
const needMoreButton = (i < tagItems.length - 1);
const requiredWidth = totalWidth + tagWidth + (needMoreButton ? moreButtonWidth + gap : 0);
if (requiredWidth > containerWidth) {
// 超出容器宽度,停止计数
break;
}
totalWidth += tagWidth + gap;
maxVisible = i + 1;
}
// 如果所有标签都能显示,则全部显示
if (maxVisible >= tagItems.length) {
tagItems.forEach(tag => tag.style.display = '');
const moreBtn = container.querySelector('.tag-more-btn');
if (moreBtn) {
moreBtn.remove();
}
container.dataset.expanded = 'false';
return;
}
// 确保至少显示1个标签
maxVisible = Math.max(1, maxVisible);
// 根据是否展开来显示/隐藏标签
const isExpanded = container.dataset.expanded === 'true';
tagItems.forEach((tag, index) => {
if (isExpanded || index < maxVisible) {
tag.style.display = '';
} else {
tag.style.display = 'none';
}
});
// 添加或更新"更多"按钮
let moreBtn = container.querySelector('.tag-more-btn');
const hiddenCount = tagItems.length - maxVisible;
if (!moreBtn) {
moreBtn = document.createElement('button');
moreBtn.type = 'button';
moreBtn.className = 'tag-more-btn inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-all bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground';
// 点击展开/收起
moreBtn.addEventListener('click', function() {
const expanded = container.dataset.expanded === 'true';
if (expanded) {
// 收起
tagItems.forEach((tag, index) => {
if (index >= maxVisible) {
tag.style.display = 'none';
}
});
container.dataset.expanded = 'false';
this.innerHTML = '<span>+' + hiddenCount + '</span>';
} else {
// 展开
tagItems.forEach(tag => tag.style.display = '');
container.dataset.expanded = 'true';
this.innerHTML = '<span>−</span>';
}
});
container.appendChild(moreBtn);
}
// 更新按钮文本
if (isExpanded) {
moreBtn.innerHTML = '<span>−</span>';
moreBtn.style.display = '';
} else {
moreBtn.innerHTML = '<span>+' + hiddenCount + '</span>';
moreBtn.style.display = '';
}
}
/**
* 初始化所有留言的响应式标签显示
*/
function initializeResponsiveTags() {
const tagContainers = document.querySelectorAll('.message-tags');
tagContainers.forEach(container => {
applyResponsiveTags(container);
});
}
// 窗口大小变化时重新计算
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
initializeResponsiveTags();
}, 200);
});
/**
* 获取当前搜索词
*/
function getSearchTerm() {
const body = document.body;
const term = body.dataset.searchTerm || '';
// 解码 HTML 实体
const textarea = document.createElement('textarea');
textarea.innerHTML = term;
return textarea.value.trim();
}
/**
* 转义正则表达式特殊字符
*/
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* 高亮文本节点中的搜索词
*/
function highlightTextNode(textNode, searchTerm) {
const text = textNode.nodeValue;
const regex = new RegExp('(' + escapeRegExp(searchTerm) + ')', 'gi');
if (!regex.test(text)) {
return;
}
const fragment = document.createDocumentFragment();
const parts = text.split(regex);
parts.forEach((part) => {
if (part.toLowerCase() === searchTerm.toLowerCase()) {
const mark = document.createElement('mark');
mark.className = 'search-highlight';
mark.textContent = part;
fragment.appendChild(mark);
} else if (part) {
fragment.appendChild(document.createTextNode(part));
}
});
textNode.parentNode.replaceChild(fragment, textNode);
}
/**
* 递归遍历 DOM 节点,对文本节点应用高亮
* 跳过 code, pre, script, style 等标签
*/
function highlightElement(element, searchTerm) {
const skipTags = new Set(['CODE', 'PRE', 'SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT']);
const walker = document.createTreeWalker(
element,
NodeFilter.SHOW_TEXT,
{
acceptNode: function(node) {
// 跳过空白文本节点
if (!node.nodeValue.trim()) {
return NodeFilter.FILTER_REJECT;
}
// 检查父节点是否在跳过列表中
let parent = node.parentNode;
while (parent && parent !== element) {
if (skipTags.has(parent.tagName)) {
return NodeFilter.FILTER_REJECT;
}
parent = parent.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
}
}
);
// 收集所有需要处理的文本节点(避免在遍历时修改 DOM)
const textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
// 对每个文本节点应用高亮
textNodes.forEach((node) => {
highlightTextNode(node, searchTerm);
});
}
/**
* 应用搜索高亮到所有留言内容
*/
function applySearchHighlight() {
const searchTerm = getSearchTerm();
if (!searchTerm) {
return;
}
const messageContents = document.querySelectorAll('.message-content');
messageContents.forEach((content) => {
highlightElement(content, searchTerm);
});
}
/**
* 初始化答复功能
*/
function initializeReplyForms(root = document) {
const scope = root && typeof root.querySelectorAll === 'function' ? root : document;
// 答复按钮点击显示/隐藏表单
scope.querySelectorAll('.reply-toggle-btn').forEach((btn) => {
if (btn.dataset.replyToggleBound === 'true') {
return;
}
btn.dataset.replyToggleBound = 'true';
btn.addEventListener('click', () => {
const messageId = btn.dataset.messageId;
const container = btn.closest('li[data-message-id]') || document;
const form = container.querySelector(`.reply-form[data-message-id="${messageId}"]`);
if (form) {
const isHidden = form.classList.contains('hidden');
form.classList.toggle('hidden', !isHidden);
if (isHidden) {
const textarea = form.querySelector('textarea');
if (textarea) {
textarea.focus();
}
}
}
});
});
// 取消按钮点击隐藏表单
scope.querySelectorAll('.reply-cancel-btn').forEach((btn) => {
if (btn.dataset.replyCancelBound === 'true') {
return;
}
btn.dataset.replyCancelBound = 'true';
btn.addEventListener('click', () => {
const form = btn.closest('.reply-form');
if (form) {
form.classList.add('hidden');
const textarea = form.querySelector('textarea');
if (textarea) {
textarea.value = '';
}
}
});
});
// 答复表单 Ctrl+Enter 提交
scope.querySelectorAll('.reply-form textarea').forEach((textarea) => {
if (textarea.dataset.replyKeydownBound === 'true') {
return;
}
textarea.dataset.replyKeydownBound = 'true';
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
textarea.form?.submit();
}
});
});
// 对答复内容应用 Markdown 渲染
renderReplyMarkdown(scope);
}
/**
* 渲染答复内容的 Markdown
*/
function renderReplyMarkdown(root = document) {
const scope = root && typeof root.querySelectorAll === 'function' ? root : document;
const replyContents = scope.querySelectorAll('.reply-content[data-markdown]');
replyContents.forEach((element) => {
const markdownText = element.getAttribute('data-markdown') || '';
if (window.marked) {
const rawHtml = marked.parse(markdownText);
const safeHtml = window.DOMPurify ? DOMPurify.sanitize(rawHtml) : rawHtml;
element.innerHTML = safeHtml;
} else {
element.textContent = markdownText;
}
});
// 高亮代码块
if (window.hljs) {
const codeBlocks = scope.querySelectorAll('.reply-content pre code');
codeBlocks.forEach((block) => window.hljs.highlightElement(block));
}
// 应用搜索高亮到答复内容
const searchTerm = getSearchTerm();
if (searchTerm) {
replyContents.forEach((content) => {
highlightElement(content, searchTerm);
});
}
}
document.addEventListener('DOMContentLoaded', () => {
initializeLanguage();
initializeTheme();
initializeMarkdownRendering();
initializeAutoRefresh();
initializeResponsiveTags();
initializeReplyForms();
const textarea = document.getElementById('message');
if (textarea) {
textarea.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && event.ctrlKey) {
event.preventDefault();
textarea.form?.submit();
}
});
const toolbarButtons = document.querySelectorAll('.toolbar-btn');
toolbarButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const action = button.getAttribute('data-action');
applyMarkdown(textarea, action);
});
});
}
});