simple-message-board
Version:
简易留言板应用,支持markdown格式
526 lines (488 loc) • 37.6 kB
JavaScript
const { MAX_MESSAGES, PAGE_SIZE } = require('../config');
const { escapeAttribute, escapeHtml, formatDisplayTime } = require('../utils/format');
const { buildListPath } = require('../utils/paths');
const { version, versionDate } = require('../../package.json');
function renderReplyItem(reply, messageId, currentPage, searchTerm, tagFilter) {
const safeMarkdown = escapeAttribute(reply.content);
const fallbackHtml = escapeHtml(reply.content);
const displayTime = formatDisplayTime(reply.created_at);
const searchHidden = searchTerm ? `<input type="hidden" name="q" value="${escapeAttribute(searchTerm)}">` : '';
const tagHidden = tagFilter ? `<input type="hidden" name="tag" value="${escapeAttribute(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 renderRepliesSection(replies, messageId, currentPage, searchTerm, tagFilter) {
const searchHidden = searchTerm ? `<input type="hidden" name="q" value="${escapeAttribute(searchTerm)}">` : '';
const tagHidden = tagFilter ? `<input type="hidden" name="tag" value="${escapeAttribute(tagFilter)}">` : '';
const repliesHtml = replies && replies.length > 0
? `<div class="replies-list divide-y divide-border/50">
${replies.map(reply => renderReplyItem(reply, messageId, currentPage, searchTerm, tagFilter)).join('')}
</div>`
: '';
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 && 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">添加答复</span>
${replies && replies.length > 0 ? `<span class="text-[10px] text-muted-foreground/70">(${replies.length})</span>` : ''}
</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="输入答复内容..." 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 renderMessageItem({ id, content, created_at, tags, replies }, currentPage, searchTerm, tagFilter) {
const safeMarkdown = escapeAttribute(content);
const fallbackHtml = escapeHtml(content);
const displayTime = formatDisplayTime(created_at);
const searchHidden = searchTerm ? `<input type="hidden" name="q" value="${escapeAttribute(searchTerm)}">` : '';
const tagHidden = tagFilter ? `<input type="hidden" name="tag" value="${escapeAttribute(tagFilter)}">` : '';
// 渲染标签 - 传递所有标签到前端,由前端根据屏幕宽度自适应显示
const tagsHtml = tags && tags.length > 0
? `<div class="message-tags flex flex-wrap gap-2 mt-3" data-all-tags='${escapeAttribute(JSON.stringify(tags))}'>
${tags.map(tag => `
<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>
${escapeHtml(tag.name)}
</a>
`).join('')}
</div>`
: '';
// 渲染答复区域
const repliesSection = renderRepliesSection(replies, id, currentPage, searchTerm, tagFilter);
return `
<li class="group/reply rounded-xl border border-border bg-card text-card-foreground shadow-sm transition hover:-translate-y-[1px] hover:shadow-md" data-message-id="${id}">
<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="${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">删除</span>
</button>
</form>
</div>
${repliesSection}
</li>
`;
}
function renderPagination(currentPage, totalPages, searchTerm = '', tagFilter = '') {
if (totalPages <= 1) {
return '';
}
const prevPage = currentPage > 1 ? currentPage - 1 : 1;
const nextPage = currentPage < totalPages ? currentPage + 1 : totalPages;
const linkBase = 'inline-flex h-9 min-w-[2.25rem] items-center justify-center rounded-md border border-input px-3 text-xs font-medium transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
const disabled = 'cursor-not-allowed bg-muted text-muted-foreground';
const active = 'bg-primary text-primary-foreground shadow hover:bg-primary/90';
const ellipsis = '<span class="px-2 text-muted-foreground">...</span>';
// 生成页码列表,使用省略号
const generatePageNumbers = () => {
const pages = [];
const showPages = new Set();
// 始终显示第一页和最后一页
showPages.add(1);
showPages.add(totalPages);
// 显示当前页及其前后各1页
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
if (i >= 1 && i <= totalPages) {
showPages.add(i);
}
}
// 如果总页数 <= 7,显示所有页码
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
showPages.add(i);
}
}
// 转换为排序数组
const sortedPages = Array.from(showPages).sort((a, b) => a - b);
// 生成带省略号的页码
let lastPage = 0;
for (const page of sortedPages) {
if (lastPage && page - lastPage > 1) {
pages.push(ellipsis);
}
const isActive = page === currentPage;
pages.push(`<a href="${buildListPath(page, searchTerm, tagFilter)}" class="${linkBase} ${isActive ? active : ''}">${page}</a>`);
lastPage = page;
}
return pages.join('');
};
return `
<nav class="flex flex-col gap-3 rounded-xl border border-border bg-card/70 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-muted-foreground" data-i18n="paginationLabel" data-current="${currentPage}" data-totalpages="${totalPages}">第 ${currentPage} / ${totalPages} 页</div>
<div class="flex flex-wrap items-center gap-2">
<a href="${buildListPath(prevPage, searchTerm, tagFilter)}" class="${linkBase} ${currentPage === 1 ? disabled : ''}" data-i18n="paginationPrev">上一页</a>
<div class="flex items-center gap-1">${generatePageNumbers()}</div>
<a href="${buildListPath(nextPage, searchTerm, tagFilter)}" class="${linkBase} ${currentPage === totalPages ? disabled : ''}" data-i18n="paginationNext">下一页</a>
</div>
</nav>
`;
}
function renderList(messages, searchTerm, currentPage, tagFilter) {
if (messages.length === 0 && searchTerm) {
const searchValueAttr = escapeAttribute(searchTerm);
const searchValueHtml = escapeHtml(searchTerm);
return `
<li class="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-border bg-card/50 p-12 text-center transition-all hover:bg-card/80">
<div class="rounded-full bg-muted p-4">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground/60"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-foreground" data-i18n="emptySearch" data-term="${searchValueAttr}">没有找到包含 "${searchValueHtml}" 的留言。</p>
<p class="text-xs text-muted-foreground">试试其他关键字?</p>
</div>
</li>
`;
}
if (messages.length === 0) {
return `
<li class="flex flex-col items-center justify-center gap-4 rounded-2xl border border-dashed border-border bg-card/50 p-12 text-center transition-all hover:bg-card/80">
<div class="rounded-full bg-muted p-4">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground/60"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</div>
<div class="space-y-1">
<p class="text-sm font-medium text-foreground" data-i18n="emptyDefault">还没有留言,快来留下第一条消息吧~</p>
</div>
</li>
`;
}
return messages.map((message) => renderMessageItem(message, currentPage, searchTerm, tagFilter)).join('');
}
function renderTagSidebar(allTags, tagFilter) {
if (!allTags || allTags.length === 0) {
return '';
}
const currentTagId = tagFilter ? String(tagFilter) : null;
const tagItems = allTags.map(tag => {
const isActive = currentTagId === String(tag.id);
let classes = "group flex items-center justify-between gap-2 py-2 px-2.5 text-xs transition-all rounded-md mb-1";
let style = "";
let countStyle = "";
if (isActive) {
classes += " font-medium shadow-sm ring-1 ring-inset";
style = `background-color: ${tag.color}15; color: ${tag.color}; --tw-ring-color: ${tag.color}40;`;
countStyle = `background-color: ${tag.color}; color: white;`;
} else {
classes += " text-muted-foreground hover:bg-muted/60 hover:text-foreground";
style = "";
countStyle = "background-color: var(--muted); color: var(--muted-foreground);";
}
return `
<a href="/?tag=${tag.id}"
class="${classes}"
style="${style}"
title="${escapeAttribute(tag.name)}">
<span class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-block h-1.5 w-1.5 rounded-full flex-shrink-0 shadow-sm" style="background-color: ${tag.color};"></span>
<span class="truncate relative top-[0.5px]">${escapeHtml(tag.name)}</span>
</span>
<span class="flex-shrink-0 rounded-md px-1.5 py-0.5 text-[10px] font-bold transition-colors group-hover:bg-background/80" style="${countStyle}">
${tag.message_count}
</span>
</a>
`;
}).join('');
return `
<aside class="fixed left-0 top-24 z-10 w-40 hidden xl:block pl-6">
<div class="rounded-xl border border-border bg-card/50 shadow-sm backdrop-blur-sm">
<div class="border-b border-border/50 px-3 py-2.5">
<div class="flex items-center justify-between">
<h2 class="flex items-center gap-1.5 text-[11px] font-bold text-muted-foreground uppercase tracking-wider">
<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="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg>
标签
</h2>
${currentTagId ? `<a href="/" class="text-[10px] text-muted-foreground hover:text-primary transition-colors">清除</a>` : ''}
</div>
</div>
<div class="max-h-[calc(100vh-10rem)] overflow-y-auto p-2 scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent">
${tagItems}
</div>
</div>
</aside>
`;
}
function renderHomePage({ messages, searchTerm, totalMessages, totalPages, currentPage, tagFilter, allTags = [] }) {
const listItems = renderList(messages, searchTerm, currentPage, tagFilter);
const pagination = renderPagination(currentPage, totalPages, searchTerm, tagFilter);
const searchValueAttr = escapeAttribute(searchTerm);
const searchValueHtml = escapeHtml(searchTerm);
// 渲染标签导航栏
const tagSidebar = renderTagSidebar(allTags, tagFilter);
return `
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>简易留言板</title>
<script>
(function() {
try {
const storedTheme = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (storedTheme === 'dark' || (!storedTheme && prefersDark)) {
document.documentElement.classList.add('dark');
}
} catch (error) {
// ignore
}
})();
</script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))'
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))'
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))'
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))'
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))'
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))'
}
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace']
}
}
}
};
</script>
<style>
:root {
color-scheme: light;
--background: 0 0% 100%;
--foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 210 40% 98%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.9rem;
}
.dark {
color-scheme: dark;
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 45.6%;
--destructive-foreground: 210 40% 98%;
--ring: 224.3 76.3% 48%;
}
</style>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600|jetbrains-mono:400,500" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="min-h-screen bg-background text-foreground" data-search-term="${searchValueAttr}" data-page-size="${PAGE_SIZE}" data-tag-filter="${tagFilter ? escapeAttribute(tagFilter) : ''}">
${tagSidebar}
<div class="relative isolate">
<div class="pointer-events-none absolute inset-x-0 top-[-14rem] -z-10 transform-gpu overflow-hidden blur-3xl" aria-hidden="true">
<div class="relative left-1/2 aspect-[1108/632] w-[72rem] -translate-x-1/2 bg-gradient-to-tr from-indigo-300 via-sky-200 to-purple-200 opacity-60 dark:from-indigo-950 dark:via-slate-800 dark:to-purple-900"></div>
</div>
<main class="mx-auto w-full max-w-5xl px-4 py-10 sm:px-6 lg:px-8">
<div class="flex flex-col gap-6">
<section class="flex flex-col gap-4 rounded-2xl border border-border bg-card/90 p-6 shadow-lg shadow-black/5 backdrop-blur">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-2">
<p class="text-xs uppercase tracking-[0.2em] text-muted-foreground">shadcn-style</p>
<h1 class="text-3xl font-semibold tracking-tight"><span data-i18n="headerTitle">简易留言板</span><span class="ml-2 text-base font-normal text-muted-foreground/60">v${version} (${versionDate})</span></h1>
<p class="text-sm text-muted-foreground" data-i18n="headerSubtitle" data-max="${MAX_MESSAGES}">支持 Markdown 留言,按 Ctrl + Enter 快速提交。最多保留 ${MAX_MESSAGES} 条。</p>
</div>
<div class="flex items-center gap-3 self-end sm:self-auto">
<span class="inline-flex items-center rounded-full bg-secondary px-3 py-1 text-xs font-medium text-secondary-foreground" data-i18n="${searchTerm ? 'statsMatches' : 'statsTotal'}" data-total="${totalMessages}">${searchTerm ? `共 ${totalMessages} 条匹配` : `共 ${totalMessages} 条留言`}</span>
<button type="button" id="language-toggle" class="inline-flex h-9 items-center gap-2 rounded-md border border-input bg-background px-3 text-xs font-medium shadow-sm transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span aria-hidden="true">🌐</span>
<span class="language-toggle-label" data-i18n="languageZh">中文</span>
</button>
<button type="button" id="theme-toggle" class="inline-flex h-9 items-center gap-2 rounded-md border border-input bg-background px-3 text-xs font-medium shadow-sm transition hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span aria-hidden="true">☀️</span>
<span class="theme-toggle-label">亮色</span>
</button>
</div>
</div>
<form action="/submit" method="post" class="space-y-3">
<div class="flex flex-wrap items-center gap-1 rounded-t-lg border border-b-0 border-border bg-muted/40 px-2 py-2">
${renderToolbarButton('heading-1', 'toolbarHeading1', '<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"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/></svg>')}
${renderToolbarButton('heading-2', 'toolbarHeading2', '<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"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>')}
<div class="mx-1 h-4 w-[1px] bg-border"></div>
${renderToolbarButton('bold', 'toolbarBold', '<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"><path d="M14 12a4 4 0 0 0 0-8H6v8"/><path d="M15 20a4 4 0 0 0 0-8H6v8Z"/></svg>')}
${renderToolbarButton('italic', 'toolbarItalic', '<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"><line x1="19" x2="10" y1="4" y2="4"/><line x1="14" x2="5" y1="20" y2="20"/><line x1="15" x2="9" y1="4" y2="20"/></svg>')}
${renderToolbarButton('quote', 'toolbarQuote', '<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"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/></svg>')}
<div class="mx-1 h-4 w-[1px] bg-border"></div>
${renderToolbarButton('list-ul', 'toolbarListUl', '<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"><line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/></svg>')}
${renderToolbarButton('list-ol', 'toolbarListOl', '<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"><line x1="10" x2="21" y1="6" y2="6"/><line x1="10" x2="21" y1="12" y2="12"/><line x1="10" x2="21" y1="18" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>')}
<div class="mx-1 h-4 w-[1px] bg-border"></div>
${renderToolbarButton('code', 'toolbarInlineCode', '<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="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>')}
${renderToolbarButton('code-block', 'toolbarCodeBlock', '<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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 10 2 2-2 2"/><path d="m15 14-2-2 2-2"/></svg>')}
${renderToolbarButton('link', 'toolbarLink', '<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"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>')}
</div>
<textarea id="message" name="message" rows="5" required placeholder="试试使用 **Markdown** 语法,支持代码块、列表等格式。" class="block w-full rounded-b-lg border border-t-0 border-input bg-background px-4 py-3 text-sm leading-6 text-foreground shadow-sm focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/40" data-i18n-placeholder="textareaPlaceholder"></textarea>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-3">
<div class="flex flex-1 items-center gap-2 rounded-md border border-input bg-background px-3 py-2.5 text-sm text-foreground shadow-sm focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/40 transition-all hover:border-ring/50">
<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" class="opacity-50"><path d="M12 2H2v10l9.29 9.29c.94.94 2.48.94 3.42 0l6.58-6.58c.94-.94.94-2.48 0-3.42L12 2Z"/><path d="M7 7h.01"/></svg>
<input type="text" name="tags" placeholder="添加标签(用逗号或空格分隔)" class="flex-1 bg-transparent text-sm placeholder:text-muted-foreground/80 focus:outline-none" data-i18n-placeholder="tagsPlaceholder">
</div>
<button type="submit" class="inline-flex h-10 items-center justify-center whitespace-nowrap rounded-md bg-primary px-6 text-sm font-semibold text-primary-foreground shadow transition-all hover:bg-primary/90 hover:scale-[1.02] active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<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" class="mr-2"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
<span data-i18n="submitButton">提交留言</span>
</button>
</div>
</form>
</section>
<section class="rounded-2xl border border-border bg-card/90 p-5 shadow-sm shadow-black/5 backdrop-blur-sm">
<div class="mb-4 flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-2">
<h2 class="flex items-center gap-2 text-sm font-semibold">
<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" class="text-primary"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span data-i18n="searchTitle">搜索留言</span>
</h2>
<span class="hidden text-xs text-muted-foreground sm:inline" data-i18n="searchSubtitle">支持模糊匹配并保留分页</span>
</div>
${searchTerm ? `<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-medium text-primary">
<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"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
<span data-i18n="searchFilter" data-term="${searchValueAttr}">已筛选:${searchValueHtml}</span>
</span>` : ''}
</div>
<form action="/" method="get" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-3">
<div class="flex flex-1 items-center gap-2 rounded-md border border-input bg-background px-4 py-2 text-sm text-foreground shadow-sm transition-all focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/40 hover:border-ring/50">
<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" class="opacity-50"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input type="search" name="q" value="${searchValueAttr}" placeholder="输入关键字" class="flex-1 bg-transparent text-sm placeholder:text-muted-foreground/80 focus:outline-none" data-i18n-placeholder="searchPlaceholder">
</div>
<div class="flex items-center gap-2">
<button type="submit" class="inline-flex h-10 items-center justify-center rounded-md bg-secondary px-4 text-sm font-medium text-secondary-foreground shadow-sm transition hover:bg-secondary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-i18n="searchButton">搜索</button>
${searchTerm ? `<a href="/" class="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-4 text-sm font-medium transition hover:bg-accent hover:text-accent-foreground" data-i18n="searchClear">清除</a>` : ''}
</div>
</form>
</section>
<section class="space-y-6">
<ul class="space-y-4">
${listItems}
</ul>
${pagination}
</section>
</div>
</main>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.6/dist/purify.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" referrerpolicy="no-referrer"></script>
<script src="/static/app.js"></script>
</body>
</html>
`;
}
function renderToolbarButton(action, key, icon) {
return `
<button type="button" class="toolbar-btn inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-action="${action}" data-i18n-title="${key}" title="${key}">
${icon}
</button>
`;
}
module.exports = { renderHomePage };