chore: checkpoint ai search comments and i18n foundation
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
|
||||
<script is:inline>
|
||||
(function() {
|
||||
const t = window.__termiTranslate;
|
||||
|
||||
function initCodeCopy() {
|
||||
const codeBlocks = document.querySelectorAll('pre code');
|
||||
|
||||
@@ -17,24 +19,24 @@
|
||||
// Create copy button
|
||||
const button = document.createElement('button');
|
||||
button.className = 'absolute top-2 right-2 px-2 py-1 text-xs rounded bg-[var(--terminal-bg)] text-[var(--text-secondary)] opacity-0 group-hover:opacity-100 transition-opacity border border-[var(--border-color)] hover:border-[var(--primary)] hover:text-[var(--primary)]';
|
||||
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
|
||||
button.innerHTML = `<i class="fas fa-copy mr-1"></i>${t('codeCopy.copy')}`;
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.textContent || '');
|
||||
button.innerHTML = '<i class="fas fa-check mr-1"></i>已复制';
|
||||
button.innerHTML = `<i class="fas fa-check mr-1"></i>${t('codeCopy.copied')}`;
|
||||
button.classList.add('text-[var(--success)]');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
|
||||
button.innerHTML = `<i class="fas fa-copy mr-1"></i>${t('codeCopy.copy')}`;
|
||||
button.classList.remove('text-[var(--success)]');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
button.innerHTML = '<i class="fas fa-times mr-1"></i>失败';
|
||||
button.innerHTML = `<i class="fas fa-times mr-1"></i>${t('codeCopy.failed')}`;
|
||||
button.classList.add('text-[var(--error)]');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
|
||||
button.innerHTML = `<i class="fas fa-copy mr-1"></i>${t('codeCopy.copy')}`;
|
||||
button.classList.remove('text-[var(--error)]');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { API_BASE_URL, apiClient } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { Comment } from '../lib/api/client';
|
||||
|
||||
interface Props {
|
||||
@@ -8,14 +9,15 @@ interface Props {
|
||||
}
|
||||
|
||||
const { postSlug, class: className = '' } = Astro.props;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
|
||||
let comments: Comment[] = [];
|
||||
let error: string | null = null;
|
||||
|
||||
try {
|
||||
comments = await apiClient.getComments(postSlug, { approved: true });
|
||||
comments = await apiClient.getComments(postSlug, { approved: true, scope: 'article' });
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : '加载评论失败';
|
||||
error = e instanceof Error ? e.message : t('comments.loadFailed');
|
||||
console.error('Failed to fetch comments:', e);
|
||||
}
|
||||
|
||||
@@ -25,11 +27,11 @@ function formatCommentDate(dateStr: string): string {
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return '今天';
|
||||
if (days === 1) return '昨天';
|
||||
if (days < 7) return `${days} 天前`;
|
||||
if (days < 30) return `${Math.floor(days / 7)} 周前`;
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
if (days === 0) return t('comments.today');
|
||||
if (days === 1) return t('comments.yesterday');
|
||||
if (days < 7) return t('comments.daysAgo', { count: days });
|
||||
if (days < 30) return t('comments.weeksAgo', { count: Math.floor(days / 7) });
|
||||
return date.toLocaleDateString(locale, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
---
|
||||
|
||||
@@ -45,9 +47,9 @@ function formatCommentDate(dateStr: string): string {
|
||||
<i class="fas fa-comments"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">评论终端</h3>
|
||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('comments.title')}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
当前缓冲区共有 {comments.length} 条已展示评论,新的留言提交后会进入审核队列。
|
||||
{t('comments.description', { count: comments.length })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +57,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
|
||||
<button type="button" id="toggle-comment-form" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-pen"></i>
|
||||
<span>write comment</span>
|
||||
<span>{t('comments.writeComment')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -64,7 +66,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="terminal-form-label">
|
||||
nickname <span class="text-[var(--primary)]">*</span>
|
||||
{t('comments.nickname')} <span class="text-[var(--primary)]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -76,7 +78,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
</div>
|
||||
<div>
|
||||
<label class="terminal-form-label">
|
||||
email <span class="text-[var(--text-tertiary)] normal-case tracking-normal">(optional)</span>
|
||||
{t('comments.email')} <span class="text-[var(--text-tertiary)] normal-case tracking-normal">({t('common.optional')})</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -89,37 +91,37 @@ function formatCommentDate(dateStr: string): string {
|
||||
|
||||
<div>
|
||||
<label class="terminal-form-label">
|
||||
message <span class="text-[var(--primary)]">*</span>
|
||||
{t('comments.message')} <span class="text-[var(--primary)]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="content"
|
||||
required
|
||||
rows="6"
|
||||
maxlength="500"
|
||||
placeholder="$ echo 'Leave your thoughts here...'"
|
||||
placeholder={t('comments.messagePlaceholder')}
|
||||
class="terminal-form-textarea resize-y"
|
||||
></textarea>
|
||||
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">max 500 chars</p>
|
||||
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">{t('comments.maxChars')}</p>
|
||||
</div>
|
||||
|
||||
<div id="replying-to" class="terminal-panel-muted hidden items-center justify-between gap-3 py-3">
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
reply -> <span id="reply-target" class="font-medium text-[var(--primary)]"></span>
|
||||
{t('common.reply')} -> <span id="reply-target" class="font-medium text-[var(--primary)]"></span>
|
||||
</span>
|
||||
<button type="button" id="cancel-reply" class="terminal-action-button">
|
||||
<i class="fas fa-xmark"></i>
|
||||
<span>cancel reply</span>
|
||||
<span>{t('comments.cancelReply')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<span>submit</span>
|
||||
<span>{t('common.submit')}</span>
|
||||
</button>
|
||||
<button type="button" id="cancel-comment" class="terminal-action-button">
|
||||
<i class="fas fa-ban"></i>
|
||||
<span>close</span>
|
||||
<span>{t('common.close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -138,9 +140,9 @@ function formatCommentDate(dateStr: string): string {
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-comment-slash"></i>
|
||||
</span>
|
||||
<h4 class="text-lg font-semibold text-[var(--title-color)]">暂无评论</h4>
|
||||
<h4 class="text-lg font-semibold text-[var(--title-color)]">{t('comments.emptyTitle')}</h4>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
当前还没有留言。可以打开上面的输入面板,作为第一个在这个终端缓冲区里发言的人。
|
||||
{t('comments.emptyDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -160,7 +162,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
|
||||
<div class="min-w-0 flex-1 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold text-[var(--title-color)]">{comment.author || '匿名'}</span>
|
||||
<span class="font-semibold text-[var(--title-color)]">{comment.author || t('comments.anonymous')}</span>
|
||||
<span class="terminal-chip px-2.5 py-1 text-xs">
|
||||
<i class="far fa-clock text-[var(--primary)]"></i>
|
||||
{formatCommentDate(comment.created_at)}
|
||||
@@ -177,14 +179,14 @@ function formatCommentDate(dateStr: string): string {
|
||||
data-id={comment.id}
|
||||
>
|
||||
<i class="fas fa-reply"></i>
|
||||
<span>reply</span>
|
||||
<span>{t('common.reply')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="like-btn terminal-action-button px-3 py-2 text-xs"
|
||||
>
|
||||
<i class="far fa-thumbs-up"></i>
|
||||
<span>like</span>
|
||||
<span>{t('common.like')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,6 +198,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const t = window.__termiTranslate;
|
||||
const wrapper = document.querySelector('.terminal-comments');
|
||||
const toggleBtn = document.getElementById('toggle-comment-form');
|
||||
const formContainer = document.getElementById('comment-form-container');
|
||||
@@ -268,7 +271,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
if (replyingTo && replyTarget) {
|
||||
replyingTo.classList.remove('hidden');
|
||||
replyingTo.classList.add('flex');
|
||||
replyTarget.textContent = author || '匿名';
|
||||
replyTarget.textContent = author || t('comments.anonymous');
|
||||
replyingTo.setAttribute('data-reply-to', commentId || '');
|
||||
}
|
||||
|
||||
@@ -289,7 +292,7 @@ function formatCommentDate(dateStr: string): string {
|
||||
const replyToId = replyingTo?.getAttribute('data-reply-to');
|
||||
|
||||
try {
|
||||
showMessage('正在提交评论...', 'info');
|
||||
showMessage(t('comments.submitting'), 'info');
|
||||
|
||||
const response = await fetch(`${apiBase}/comments`, {
|
||||
method: 'POST',
|
||||
@@ -301,7 +304,8 @@ function formatCommentDate(dateStr: string): string {
|
||||
nickname: formData.get('nickname'),
|
||||
email: formData.get('email'),
|
||||
content: formData.get('content'),
|
||||
replyTo: replyToId || null,
|
||||
scope: 'article',
|
||||
replyToCommentId: replyToId ? Number(replyToId) : null,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -313,9 +317,9 @@ function formatCommentDate(dateStr: string): string {
|
||||
replyingTo?.classList.remove('flex');
|
||||
resetReply();
|
||||
formContainer?.classList.add('hidden');
|
||||
showMessage('评论已提交,审核通过后会显示在这里。', 'success');
|
||||
showMessage(t('comments.submitSuccess'), 'success');
|
||||
} catch (error) {
|
||||
showMessage(`提交失败:${error instanceof Error ? error.message : 'unknown error'}`, 'error');
|
||||
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
import { terminalConfig } from '../lib/config/terminal';
|
||||
import { DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -8,8 +8,13 @@ interface Props {
|
||||
}
|
||||
|
||||
const { siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
const social = siteSettings.social;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const tools = [
|
||||
{ icon: 'fa-sitemap', href: '/sitemap.xml', title: t('footer.sitemap') },
|
||||
{ icon: 'fa-rss', href: '/rss.xml', title: t('footer.rss') },
|
||||
];
|
||||
---
|
||||
|
||||
<footer class="border-t border-[var(--border-color)]/70 mt-auto py-8">
|
||||
@@ -18,13 +23,13 @@ const currentYear = new Date().getFullYear();
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="terminal-toolbar-module min-w-[14rem]">
|
||||
<div class="min-w-0">
|
||||
<div class="terminal-toolbar-label">session</div>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">© {currentYear} {siteSettings.siteName}. All rights reserved.</p>
|
||||
<div class="terminal-toolbar-label">{t('footer.session')}</div>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{t('footer.copyright', { year: currentYear, site: siteSettings.siteName })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
{terminalConfig.tools.map(tool => (
|
||||
{tools.map(tool => (
|
||||
<a
|
||||
href={tool.href}
|
||||
class="terminal-toolbar-iconbtn"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { API_BASE_URL, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -8,6 +9,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
---
|
||||
|
||||
<div
|
||||
@@ -21,21 +23,21 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<div class="terminal-kicker">friend-link request</div>
|
||||
<h3 class="mt-3 text-xl font-bold text-[var(--title-color)]">提交友链申请</h3>
|
||||
<h3 class="mt-3 text-xl font-bold text-[var(--title-color)]">{t('friendForm.title')}</h3>
|
||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||
填写站点信息后会提交到后台审核,审核通过后前台会自动展示。
|
||||
{t('friendForm.intro')}
|
||||
</p>
|
||||
</div>
|
||||
<div class="terminal-stat-pill self-start sm:self-auto">
|
||||
<i class="fas fa-shield-alt text-[var(--primary)]"></i>
|
||||
<span>后台审核后上线</span>
|
||||
<span>{t('friendForm.reviewedOnline')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||
<i class="fas fa-user mr-1"></i>站点名称 <span class="text-[var(--primary)]">*</span>
|
||||
<i class="fas fa-user mr-1"></i>{t('friendForm.siteName')} <span class="text-[var(--primary)]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -47,7 +49,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||
<i class="fas fa-link mr-1"></i>站点链接 <span class="text-[var(--primary)]">*</span>
|
||||
<i class="fas fa-link mr-1"></i>{t('friendForm.siteUrl')} <span class="text-[var(--primary)]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -62,7 +64,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||
<i class="fas fa-image mr-1"></i>头像链接 <span class="text-[var(--text-tertiary)] text-xs">(可选)</span>
|
||||
<i class="fas fa-image mr-1"></i>{t('friendForm.avatarUrl')} <span class="text-[var(--text-tertiary)] text-xs">({t('common.optional')})</span>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
@@ -73,14 +75,19 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-[var(--text-secondary)] mb-2">
|
||||
<i class="fas fa-folder mr-1"></i>分类 <span class="text-[var(--text-tertiary)] text-xs">(可选)</span>
|
||||
<i class="fas fa-folder mr-1"></i>{t('friendForm.category')} <span class="text-[var(--text-tertiary)] text-xs">({t('common.optional')})</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{['tech', 'life', 'design', 'other'].map(category => (
|
||||
{[
|
||||
{ value: 'tech', label: t('friendForm.categoryTech') },
|
||||
{ value: 'life', label: t('friendForm.categoryLife') },
|
||||
{ value: 'design', label: t('friendForm.categoryDesign') },
|
||||
{ value: 'other', label: t('friendForm.categoryOther') },
|
||||
].map(category => (
|
||||
<label class="ui-filter-pill ui-filter-pill--amber cursor-pointer">
|
||||
<input type="radio" name="category" value={category} class="sr-only" />
|
||||
<input type="radio" name="category" value={category.value} class="sr-only" />
|
||||
<i class="fas fa-angle-right text-[10px] opacity-70"></i>
|
||||
<span class="text-sm">[{category}]</span>
|
||||
<span class="text-sm">[{category.label}]</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -89,17 +96,17 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
|
||||
<div>
|
||||
<label class="block text-sm text-[var(--text-secondary)] mb-1.5">
|
||||
<i class="fas fa-align-left mr-1"></i>站点描述 <span class="text-[var(--primary)]">*</span>
|
||||
<i class="fas fa-align-left mr-1"></i>{t('friendForm.description')} <span class="text-[var(--primary)]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
required
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
placeholder="describe your site..."
|
||||
placeholder={t('friendForm.descriptionPlaceholder')}
|
||||
class="terminal-form-textarea resize-none"
|
||||
></textarea>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 text-right">最多 200 字</p>
|
||||
<p class="text-xs text-[var(--text-tertiary)] mt-1 text-right">{t('common.maxChars', { count: 200 })}</p>
|
||||
</div>
|
||||
|
||||
<div class="terminal-panel-muted flex items-start gap-3">
|
||||
@@ -110,35 +117,35 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
class="mt-1 h-4 w-4 rounded border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--primary)] focus:ring-[var(--primary)]"
|
||||
/>
|
||||
<label for="has-reciprocal" class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||
已添加本站友链 <span class="text-[var(--primary)]">*</span>
|
||||
<span class="block text-xs text-[var(--text-tertiary)]">这是提交申请前的必要条件。</span>
|
||||
{t('friendForm.reciprocal')} <span class="text-[var(--primary)]">*</span>
|
||||
<span class="block text-xs text-[var(--text-tertiary)]">{t('friendForm.reciprocalHint')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="reciprocal-info" class="terminal-panel-muted hidden">
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
<i class="fas fa-info-circle mr-1"></i>本站信息:
|
||||
<i class="fas fa-info-circle mr-1"></i>{t('friends.siteInfo')}
|
||||
</p>
|
||||
<div class="space-y-1 text-sm">
|
||||
<p class="flex items-center gap-2">
|
||||
<span class="text-[var(--text-tertiary)]">名称:</span>
|
||||
<span class="text-[var(--text-tertiary)]">{t('friends.name')}:</span>
|
||||
<span class="text-[var(--text)] font-medium">{siteSettings.siteName}</span>
|
||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteName}>
|
||||
<i class="fas fa-copy"></i>复制
|
||||
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||
</button>
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<span class="text-[var(--text-tertiary)]">链接:</span>
|
||||
<span class="text-[var(--text-tertiary)]">{t('friends.link')}:</span>
|
||||
<span class="text-[var(--text)]">{siteSettings.siteUrl}</span>
|
||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteUrl}>
|
||||
<i class="fas fa-copy"></i>复制
|
||||
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||
</button>
|
||||
</p>
|
||||
<p class="flex items-center gap-2">
|
||||
<span class="text-[var(--text-tertiary)]">描述:</span>
|
||||
<span class="text-[var(--text-tertiary)]">{t('friends.description')}:</span>
|
||||
<span class="text-[var(--text)]">{siteSettings.siteDescription}</span>
|
||||
<button type="button" class="copy-btn text-[var(--primary)] hover:underline text-xs" data-text={siteSettings.siteDescription}>
|
||||
<i class="fas fa-copy"></i>复制
|
||||
<i class="fas fa-copy"></i>{t('friendForm.copy')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
@@ -149,13 +156,13 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
type="submit"
|
||||
class="terminal-action-button terminal-action-button-primary"
|
||||
>
|
||||
<i class="fas fa-paper-plane"></i>提交申请
|
||||
<i class="fas fa-paper-plane"></i>{t('friendForm.submit')}
|
||||
</button>
|
||||
<button
|
||||
type="reset"
|
||||
class="terminal-action-button"
|
||||
>
|
||||
<i class="fas fa-undo"></i>重置
|
||||
<i class="fas fa-undo"></i>{t('friendForm.reset')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +171,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const t = window.__termiTranslate;
|
||||
const wrapper = document.querySelector('.terminal-friend-link-form');
|
||||
const form = document.getElementById('friend-link-form') as HTMLFormElement | null;
|
||||
const reciprocalCheckbox = document.getElementById('has-reciprocal') as HTMLInputElement | null;
|
||||
@@ -181,7 +189,7 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
const text = btn.getAttribute('data-text') || '';
|
||||
await navigator.clipboard.writeText(text);
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i>已复制';
|
||||
btn.innerHTML = `<i class="fas fa-check"></i>${t('friendForm.copied')}`;
|
||||
setTimeout(() => {
|
||||
btn.innerHTML = originalHTML;
|
||||
}, 1800);
|
||||
@@ -211,12 +219,12 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
const hasReciprocal = formData.get('hasReciprocal') === 'on';
|
||||
|
||||
if (!hasReciprocal) {
|
||||
showMessage('请先添加本站友链后再提交申请。', 'error');
|
||||
showMessage(t('friendForm.addReciprocalFirst'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showMessage('正在提交友链申请...', 'info');
|
||||
showMessage(t('friendForm.submitting'), 'info');
|
||||
|
||||
const response = await fetch(`${apiBase}/friend_links`, {
|
||||
method: 'POST',
|
||||
@@ -238,9 +246,9 @@ const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.pr
|
||||
|
||||
form.reset();
|
||||
reciprocalInfo?.classList.add('hidden');
|
||||
showMessage('友链申请已提交,我们会尽快审核。', 'success');
|
||||
showMessage(t('friendForm.submitSuccess'), 'success');
|
||||
} catch (error) {
|
||||
showMessage(`提交失败:${error instanceof Error ? error.message : 'unknown error'}`, 'error');
|
||||
showMessage(t('friendForm.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
import { getI18n } from '../lib/i18n';
|
||||
import type { FriendLink } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -6,6 +7,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { friend } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
---
|
||||
|
||||
<a
|
||||
@@ -58,11 +60,11 @@ const { friend } = Astro.props;
|
||||
<span>{friend.category}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">external link</span>
|
||||
<span class="text-xs text-[var(--text-tertiary)] font-mono">{t('friendCard.externalLink')}</span>
|
||||
)}
|
||||
|
||||
<span class="terminal-link-arrow">
|
||||
<span>访问</span>
|
||||
<span>{t('common.visit')}</span>
|
||||
<i class="fas fa-arrow-up-right-from-square text-xs"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { terminalConfig } from '../lib/config/terminal';
|
||||
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
|
||||
import type { SiteSettings } from '../lib/types';
|
||||
|
||||
interface Props {
|
||||
@@ -11,11 +12,28 @@ const {
|
||||
siteName = Astro.props.siteSettings?.siteShortName || terminalConfig.branding?.shortName || 'Termi'
|
||||
} = Astro.props;
|
||||
|
||||
const navItems = terminalConfig.navLinks;
|
||||
const { locale, t, buildLocaleUrl } = getI18n(Astro);
|
||||
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
|
||||
const navItems = [
|
||||
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
|
||||
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
|
||||
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
|
||||
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
|
||||
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
|
||||
...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
|
||||
];
|
||||
const localeLinks = SUPPORTED_LOCALES.map((item) => ({
|
||||
locale: item,
|
||||
href: buildLocaleUrl(item),
|
||||
label: t(`common.languages.${item}`),
|
||||
shortLabel: item === 'zh-CN' ? '中' : 'EN',
|
||||
}));
|
||||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<header class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
|
||||
<header data-ai-search-enabled={aiEnabled ? 'true' : 'false'} class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-3">
|
||||
<div class="terminal-toolbar-shell">
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -54,17 +72,39 @@ const currentPath = Astro.url.pathname;
|
||||
</div>
|
||||
|
||||
<div class="relative hidden md:block flex-1 min-w-0">
|
||||
<div class="terminal-toolbar-module">
|
||||
<div class="terminal-toolbar-label">grep -i</div>
|
||||
<div class="terminal-toolbar-module gap-3">
|
||||
<div class="terminal-toolbar-label" id="search-label">grep -i</div>
|
||||
{aiEnabled && (
|
||||
<div id="search-mode-panel" class="flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="keyword"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<i class="fas fa-search mr-1 text-[11px]"></i>
|
||||
<span>{t('header.searchModeKeyword')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn rounded-lg px-3 py-2 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="ai"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i class="fas fa-robot mr-1 text-[11px]"></i>
|
||||
<span>{t('header.searchModeAi')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
id="search-input"
|
||||
placeholder="'关键词'"
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<span class="hidden xl:inline text-xs font-mono text-[var(--secondary)]">articles/*.md</span>
|
||||
<span id="search-hint" class="hidden xl:inline text-xs font-mono text-[var(--secondary)]">articles/*.md</span>
|
||||
<button id="search-btn" class="terminal-toolbar-iconbtn">
|
||||
<i class="fas fa-search text-sm"></i>
|
||||
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -73,11 +113,30 @@ const currentPath = Astro.url.pathname;
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="hidden sm:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
{localeLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
data-locale-switch={item.locale}
|
||||
class:list={[
|
||||
'rounded-lg px-3 py-2 text-xs font-semibold transition',
|
||||
item.locale === locale
|
||||
? 'bg-[var(--primary)] text-white shadow-sm'
|
||||
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
|
||||
]}
|
||||
aria-current={item.locale === locale ? 'true' : undefined}
|
||||
title={item.label}
|
||||
>
|
||||
{item.shortLabel}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="theme-toggle"
|
||||
class="theme-toggle terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
||||
aria-label="切换主题"
|
||||
title="切换主题"
|
||||
aria-label={t('header.themeToggle')}
|
||||
title={t('header.themeToggle')}
|
||||
>
|
||||
<i id="theme-icon" class="fas fa-moon text-[var(--primary)]"></i>
|
||||
</button>
|
||||
@@ -85,14 +144,14 @@ const currentPath = Astro.url.pathname;
|
||||
<button
|
||||
id="mobile-menu-btn"
|
||||
class="lg:hidden terminal-toolbar-iconbtn h-11 w-11 shrink-0"
|
||||
aria-label="Toggle menu"
|
||||
aria-label={t('header.toggleMenu')}
|
||||
>
|
||||
<i class="fas fa-bars text-[var(--text)]"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex items-center gap-3 border-t border-[var(--border-color)]/70 pt-3">
|
||||
<div class="terminal-toolbar-label">navigation</div>
|
||||
<div class="terminal-toolbar-label">{t('header.navigation')}</div>
|
||||
<nav class="min-w-0 flex-1 flex items-center gap-1.5 overflow-x-auto pb-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
|
||||
@@ -117,17 +176,62 @@ const currentPath = Astro.url.pathname;
|
||||
<!-- Mobile Menu -->
|
||||
<div id="mobile-menu" class="hidden lg:hidden border-t border-[var(--border-color)] bg-[var(--bg)]">
|
||||
<div class="px-4 py-3 space-y-3">
|
||||
<div class="terminal-toolbar-module md:hidden">
|
||||
<span class="terminal-toolbar-label">grep -i</span>
|
||||
<input
|
||||
type="text"
|
||||
id="mobile-search-input"
|
||||
placeholder="'关键词'"
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
||||
<i class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
<div class="space-y-3 md:hidden">
|
||||
{aiEnabled && (
|
||||
<div class="flex items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="keyword"
|
||||
aria-pressed="true"
|
||||
>
|
||||
<i class="fas fa-search mr-2 text-xs"></i>
|
||||
<span>{t('header.searchModeKeywordMobile')}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="search-mode-btn flex-1 rounded-xl px-3 py-2 text-sm font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
|
||||
data-search-mode="ai"
|
||||
aria-pressed="false"
|
||||
>
|
||||
<i class="fas fa-robot mr-2 text-xs"></i>
|
||||
<span>{t('header.searchModeAiMobile')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="terminal-toolbar-label">{t('common.language')}</span>
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
{localeLinks.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
data-locale-switch={item.locale}
|
||||
class:list={[
|
||||
'flex-1 rounded-xl border px-3 py-2 text-center text-sm font-medium transition',
|
||||
item.locale === locale
|
||||
? 'border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]'
|
||||
: 'border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text-secondary)]'
|
||||
]}
|
||||
aria-current={item.locale === locale ? 'true' : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-toolbar-module">
|
||||
<span class="terminal-toolbar-label" id="mobile-search-label">grep -i</span>
|
||||
<input
|
||||
type="text"
|
||||
id="mobile-search-input"
|
||||
placeholder={t('header.searchPlaceholderKeyword')}
|
||||
class="terminal-console-input"
|
||||
/>
|
||||
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
|
||||
<i id="mobile-search-btn-icon" class="fas fa-search text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p id="mobile-search-hint" class="px-1 text-xs font-mono text-[var(--text-tertiary)]">articles/*.md</p>
|
||||
</div>
|
||||
{navItems.map(item => (
|
||||
<a
|
||||
@@ -320,11 +424,39 @@ const currentPath = Astro.url.pathname;
|
||||
updateTitle();
|
||||
|
||||
// Search functionality
|
||||
const headerRoot = document.querySelector('header[data-ai-search-enabled]');
|
||||
const aiSearchEnabled = headerRoot?.getAttribute('data-ai-search-enabled') === 'true';
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const searchBtn = document.getElementById('search-btn');
|
||||
const searchBtnIcon = document.getElementById('search-btn-icon');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
const searchLabel = document.getElementById('search-label');
|
||||
const searchHint = document.getElementById('search-hint');
|
||||
const mobileSearchLabel = document.getElementById('mobile-search-label');
|
||||
const mobileSearchHint = document.getElementById('mobile-search-hint');
|
||||
const mobileSearchBtnIcon = document.getElementById('mobile-search-btn-icon');
|
||||
const searchModePanel = document.getElementById('search-mode-panel');
|
||||
const searchModeButtons = Array.from(document.querySelectorAll('.search-mode-btn'));
|
||||
const localeSwitchLinks = Array.from(document.querySelectorAll('[data-locale-switch]'));
|
||||
const searchApiBase = 'http://localhost:5150/api';
|
||||
const searchInputs = [searchInput, mobileSearchInput].filter(Boolean);
|
||||
const t = window.__termiTranslate;
|
||||
const searchModeConfig = {
|
||||
keyword: {
|
||||
label: 'grep -i',
|
||||
hint: t('header.searchHintKeyword'),
|
||||
placeholder: t('header.searchPlaceholderKeyword'),
|
||||
buttonIcon: 'fa-search'
|
||||
},
|
||||
ai: {
|
||||
label: 'ask ai',
|
||||
hint: t('header.searchHintAi'),
|
||||
placeholder: t('header.searchPlaceholderAi'),
|
||||
buttonIcon: 'fa-robot'
|
||||
}
|
||||
};
|
||||
let searchTimer = null;
|
||||
let currentSearchMode = 'keyword';
|
||||
|
||||
function escapeHtml(value) {
|
||||
return value
|
||||
@@ -349,6 +481,102 @@ const currentPath = Astro.url.pathname;
|
||||
);
|
||||
}
|
||||
|
||||
function syncSearchInputs(sourceInput) {
|
||||
const nextValue = sourceInput && 'value' in sourceInput ? sourceInput.value : '';
|
||||
searchInputs.forEach((input) => {
|
||||
if (input !== sourceInput) {
|
||||
input.value = nextValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getQueryFromInput(input) {
|
||||
return input && 'value' in input ? input.value.trim() : '';
|
||||
}
|
||||
|
||||
function buildLocalizedUrl(path) {
|
||||
const nextUrl = new URL(path, window.location.origin);
|
||||
nextUrl.searchParams.set('lang', document.documentElement.lang || 'zh-CN');
|
||||
return `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
}
|
||||
|
||||
function buildSearchTarget(query) {
|
||||
return buildLocalizedUrl(
|
||||
currentSearchMode === 'ai'
|
||||
? `/ask?q=${encodeURIComponent(query)}`
|
||||
: `/articles?search=${encodeURIComponent(query)}`
|
||||
);
|
||||
}
|
||||
|
||||
function syncSearchModeUI() {
|
||||
const config = searchModeConfig[currentSearchMode] || searchModeConfig.keyword;
|
||||
|
||||
if (searchLabel) {
|
||||
searchLabel.textContent = config.label;
|
||||
}
|
||||
if (mobileSearchLabel) {
|
||||
mobileSearchLabel.textContent = config.label;
|
||||
}
|
||||
if (searchHint) {
|
||||
searchHint.textContent = config.hint;
|
||||
}
|
||||
if (mobileSearchHint) {
|
||||
mobileSearchHint.textContent = config.hint;
|
||||
}
|
||||
|
||||
searchInputs.forEach((input) => {
|
||||
input.setAttribute('placeholder', config.placeholder);
|
||||
});
|
||||
|
||||
if (searchBtnIcon) {
|
||||
searchBtnIcon.className = `fas ${config.buttonIcon} text-sm`;
|
||||
}
|
||||
if (mobileSearchBtnIcon) {
|
||||
mobileSearchBtnIcon.className = `fas ${config.buttonIcon} text-sm`;
|
||||
}
|
||||
|
||||
searchModeButtons.forEach((button) => {
|
||||
const isActive = button.getAttribute('data-search-mode') === currentSearchMode;
|
||||
button.setAttribute('aria-pressed', String(isActive));
|
||||
button.classList.toggle('bg-[var(--primary)]', isActive);
|
||||
button.classList.toggle('text-white', isActive);
|
||||
button.classList.toggle('shadow-sm', isActive);
|
||||
button.classList.toggle('text-[var(--text-secondary)]', !isActive);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAiSearchResults(query) {
|
||||
if (!searchResults) return;
|
||||
|
||||
searchResults.innerHTML = `
|
||||
<div class="overflow-hidden">
|
||||
<div class="border-b border-[var(--border-color)] px-4 py-2 text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
${escapeHtml(t('header.aiModeTitle'))}
|
||||
</div>
|
||||
<div class="space-y-4 px-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-semibold text-[var(--title-color)]">${escapeHtml(t('header.aiModeHeading'))}</div>
|
||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">
|
||||
${escapeHtml(t('header.aiModeDescription'))}
|
||||
</p>
|
||||
<p class="text-xs leading-5 text-[var(--text-tertiary)]">
|
||||
${escapeHtml(t('header.aiModeNotice'))}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-3">
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">${escapeHtml(t('common.search'))}</div>
|
||||
<div class="mt-2 font-mono text-sm text-[var(--title-color)]">${escapeHtml(query)}</div>
|
||||
</div>
|
||||
<a href="${buildSearchTarget(query)}" class="flex items-center justify-between rounded-2xl border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-4 py-3 text-sm font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
|
||||
<span><i class="fas fa-robot mr-2 text-xs"></i>${escapeHtml(t('header.aiModeCta'))}</span>
|
||||
<i class="fas fa-arrow-right text-xs"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSearchResults() {
|
||||
if (!searchResults) return;
|
||||
searchResults.classList.add('hidden');
|
||||
@@ -361,7 +589,7 @@ const currentPath = Astro.url.pathname;
|
||||
if (state === 'loading') {
|
||||
searchResults.innerHTML = `
|
||||
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
|
||||
正在搜索 <span class="text-[var(--primary)] font-mono">${escapeHtml(query)}</span> ...
|
||||
${escapeHtml(t('header.searching', { query }))}
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove('hidden');
|
||||
@@ -371,7 +599,7 @@ const currentPath = Astro.url.pathname;
|
||||
if (state === 'error') {
|
||||
searchResults.innerHTML = `
|
||||
<div class="px-4 py-4 text-sm text-[var(--danger)]">
|
||||
搜索失败,请稍后再试。
|
||||
${escapeHtml(t('header.searchFailed'))}
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove('hidden');
|
||||
@@ -379,9 +607,18 @@ const currentPath = Astro.url.pathname;
|
||||
}
|
||||
|
||||
if (!results.length) {
|
||||
const aiRetry = aiSearchEnabled
|
||||
? `
|
||||
<a href="${buildLocalizedUrl(`/ask?q=${encodeURIComponent(query)}`)}" class="mt-3 inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/30 bg-[var(--primary)]/10 px-3 py-1.5 text-xs font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
|
||||
<i class="fas fa-robot text-[11px]"></i>
|
||||
<span>${escapeHtml(t('header.searchEmptyCta'))}</span>
|
||||
</a>
|
||||
`
|
||||
: '';
|
||||
searchResults.innerHTML = `
|
||||
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
|
||||
没有找到和 <span class="text-[var(--primary)] font-mono">${escapeHtml(query)}</span> 相关的内容。
|
||||
${escapeHtml(t('header.searchEmpty', { query }))}
|
||||
${aiRetry}
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove('hidden');
|
||||
@@ -399,7 +636,7 @@ const currentPath = Astro.url.pathname;
|
||||
return `
|
||||
<a href="/articles/${encodeURIComponent(item.slug)}" class="block border-b border-[var(--border-color)] px-4 py-3 transition-colors hover:bg-[var(--header-bg)] last:border-b-0">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-sm font-semibold text-[var(--title-color)]">${highlightText(item.title || 'Untitled', query)}</div>
|
||||
<div class="text-sm font-semibold text-[var(--title-color)]">${highlightText(item.title || t('header.untitled'), query)}</div>
|
||||
<div class="text-[11px] text-[var(--text-tertiary)]">${escapeHtml(item.category || '')}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-xs leading-5 text-[var(--text-secondary)]">${highlightText(item.description || item.content || '', query)}</div>
|
||||
@@ -408,15 +645,25 @@ const currentPath = Astro.url.pathname;
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const aiFooter = aiSearchEnabled
|
||||
? `
|
||||
<a href="${buildLocalizedUrl(`/ask?q=${encodeURIComponent(query)}`)}" class="block border-t border-[var(--border-color)] px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
||||
<i class="fas fa-robot mr-2 text-xs"></i>
|
||||
${escapeHtml(t('header.searchAiFooter'))}
|
||||
</a>
|
||||
`
|
||||
: '';
|
||||
|
||||
searchResults.innerHTML = `
|
||||
<div class="max-h-[26rem] overflow-auto">
|
||||
<div class="border-b border-[var(--border-color)] px-4 py-2 text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
||||
实时搜索结果
|
||||
${escapeHtml(t('header.liveResults'))}
|
||||
</div>
|
||||
${itemsHtml}
|
||||
<a href="/articles?search=${encodeURIComponent(query)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
||||
查看全部结果
|
||||
<a href="${buildLocalizedUrl(`/articles?search=${encodeURIComponent(query)}`)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
|
||||
${escapeHtml(t('header.searchAllResults'))}
|
||||
</a>
|
||||
${aiFooter}
|
||||
</div>
|
||||
`;
|
||||
searchResults.classList.remove('hidden');
|
||||
@@ -428,6 +675,11 @@ const currentPath = Astro.url.pathname;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentSearchMode === 'ai') {
|
||||
renderAiSearchResults(query);
|
||||
return;
|
||||
}
|
||||
|
||||
renderSearchResults(query, [], 'loading');
|
||||
|
||||
try {
|
||||
@@ -444,38 +696,59 @@ const currentPath = Astro.url.pathname;
|
||||
}
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
const query = searchInput && 'value' in searchInput ? searchInput.value.trim() : '';
|
||||
if (query) {
|
||||
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
||||
function setSearchMode(mode) {
|
||||
if (!aiSearchEnabled && mode === 'ai') {
|
||||
currentSearchMode = 'keyword';
|
||||
} else {
|
||||
currentSearchMode = mode;
|
||||
}
|
||||
|
||||
syncSearchModeUI();
|
||||
|
||||
const query = getQueryFromInput(searchInput);
|
||||
if (query && document.activeElement === searchInput) {
|
||||
void runLiveSearch(query);
|
||||
} else if (!query) {
|
||||
hideSearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
function submitSearch(preferredInput) {
|
||||
const query = getQueryFromInput(preferredInput) || getQueryFromInput(searchInput) || getQueryFromInput(mobileSearchInput);
|
||||
if (query) {
|
||||
window.location.href = buildSearchTarget(query);
|
||||
}
|
||||
}
|
||||
|
||||
searchModeButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
const nextMode = button.getAttribute('data-search-mode') || 'keyword';
|
||||
setSearchMode(nextMode);
|
||||
});
|
||||
});
|
||||
|
||||
searchBtn?.addEventListener('click', function() {
|
||||
submitSearch();
|
||||
submitSearch(searchInput);
|
||||
});
|
||||
mobileSearchBtn?.addEventListener('click', function() {
|
||||
const query = mobileSearchInput && 'value' in mobileSearchInput ? mobileSearchInput.value.trim() : '';
|
||||
if (query) {
|
||||
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
||||
}
|
||||
submitSearch(mobileSearchInput);
|
||||
});
|
||||
|
||||
searchInput?.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
submitSearch();
|
||||
e.preventDefault();
|
||||
submitSearch(searchInput);
|
||||
}
|
||||
});
|
||||
mobileSearchInput?.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
const query = this.value.trim();
|
||||
if (query) {
|
||||
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
|
||||
}
|
||||
e.preventDefault();
|
||||
submitSearch(mobileSearchInput);
|
||||
}
|
||||
});
|
||||
|
||||
searchInput?.addEventListener('input', function() {
|
||||
syncSearchInputs(searchInput);
|
||||
const query = this.value.trim();
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
@@ -485,6 +758,10 @@ const currentPath = Astro.url.pathname;
|
||||
}, 180);
|
||||
});
|
||||
|
||||
mobileSearchInput?.addEventListener('input', function() {
|
||||
syncSearchInputs(mobileSearchInput);
|
||||
});
|
||||
|
||||
searchInput?.addEventListener('focus', function() {
|
||||
const query = this.value.trim();
|
||||
if (query) {
|
||||
@@ -492,11 +769,27 @@ const currentPath = Astro.url.pathname;
|
||||
}
|
||||
});
|
||||
|
||||
syncSearchModeUI();
|
||||
|
||||
localeSwitchLinks.forEach((link) => {
|
||||
link.addEventListener('click', () => {
|
||||
const nextLocale = link.getAttribute('data-locale-switch');
|
||||
if (!nextLocale) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('locale', nextLocale);
|
||||
document.cookie = `${'termi_locale'}=${encodeURIComponent(nextLocale)};path=/;max-age=31536000;samesite=lax`;
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(event) {
|
||||
const target = event.target;
|
||||
if (
|
||||
searchResults &&
|
||||
!searchResults.contains(target) &&
|
||||
!searchModePanel?.contains(target) &&
|
||||
!target?.closest?.('.search-mode-btn') &&
|
||||
target !== searchInput &&
|
||||
target !== searchBtn &&
|
||||
!searchBtn?.contains(target)
|
||||
|
||||
748
frontend/src/components/ParagraphComments.astro
Normal file
748
frontend/src/components/ParagraphComments.astro
Normal file
@@ -0,0 +1,748 @@
|
||||
---
|
||||
import { API_BASE_URL } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
postSlug: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { postSlug, class: className = '' } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
---
|
||||
|
||||
<div class={`paragraph-comments-shell ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
|
||||
<div class="terminal-panel-muted paragraph-comments-intro">
|
||||
<div class="space-y-3">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-paragraph"></i>
|
||||
paragraph annotations
|
||||
</span>
|
||||
<div class="space-y-2">
|
||||
<h3 class="text-lg font-semibold text-[var(--title-color)]">{t('paragraphComments.title')}</h3>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
{t('paragraphComments.intro')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paragraph-comments-summary terminal-chip">
|
||||
<i class="fas fa-terminal text-[var(--primary)]"></i>
|
||||
<span data-summary-text>{t('paragraphComments.scanning')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const t = window.__termiTranslate;
|
||||
const locale = document.documentElement.lang || 'zh-CN';
|
||||
import { buildParagraphDescriptors } from '../lib/utils/paragraph-comments';
|
||||
|
||||
interface BrowserComment {
|
||||
id: number;
|
||||
author: string | null;
|
||||
content: string | null;
|
||||
created_at: string;
|
||||
reply_to_comment_id: number | null;
|
||||
paragraph_excerpt: string | null;
|
||||
}
|
||||
|
||||
interface PendingComment {
|
||||
id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
reply_to_comment_id: number | null;
|
||||
}
|
||||
|
||||
interface SummaryItem {
|
||||
paragraph_key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
const wrappers = document.querySelectorAll('.paragraph-comments-shell');
|
||||
const wrapper = wrappers.item(wrappers.length - 1) as HTMLElement | null;
|
||||
const postSlug = wrapper?.dataset.postSlug || '';
|
||||
const apiBase = wrapper?.dataset.apiBase || 'http://localhost:5150/api';
|
||||
const articleRoot = wrapper?.closest('[data-article-slug]') || document;
|
||||
const articleContent = articleRoot.querySelector('.article-content') as HTMLElement | null;
|
||||
const summaryText = wrapper?.querySelector('[data-summary-text]') as HTMLElement | null;
|
||||
|
||||
const paragraphCounts = new Map<string, number>();
|
||||
const paragraphRows = new Map<string, HTMLElement>();
|
||||
const paragraphDescriptors = new Map<
|
||||
string,
|
||||
ReturnType<typeof buildParagraphDescriptors>[number]
|
||||
>();
|
||||
const threadCache = new Map<string, BrowserComment[]>();
|
||||
const pendingComments = new Map<string, PendingComment[]>();
|
||||
|
||||
let activeParagraphKey: string | null = null;
|
||||
let activeReplyToCommentId: number | null = null;
|
||||
let pendingCounter = 0;
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function formatCommentDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString(locale, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function setSummaryMessage(message: string) {
|
||||
if (summaryText) {
|
||||
summaryText.textContent = message;
|
||||
}
|
||||
}
|
||||
|
||||
function countLabel(count: number): string {
|
||||
if (count <= 0) {
|
||||
return t('paragraphComments.zeroNotes');
|
||||
}
|
||||
if (count === 1) {
|
||||
return t('paragraphComments.oneNote');
|
||||
}
|
||||
return t('paragraphComments.manyNotes', { count });
|
||||
}
|
||||
|
||||
function previewReplyText(value: string | null | undefined, limit = 88) {
|
||||
const normalized = (value || '').replace(/\s+/g, ' ').trim();
|
||||
if (normalized.length <= limit) {
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
return `${normalized.slice(0, limit).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function promptLabel(key: string, active: boolean) {
|
||||
return active ? `./comment --paragraph ${key} --open` : `./comment --paragraph ${key}`;
|
||||
}
|
||||
|
||||
function anchorForParagraph(key: string) {
|
||||
return `#paragraph-${key}`;
|
||||
}
|
||||
|
||||
function paragraphKeyFromHash(hash: string) {
|
||||
const normalized = hash.startsWith('#') ? hash.slice(1) : hash;
|
||||
if (!normalized.startsWith('paragraph-')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = normalized.slice('paragraph-'.length).trim();
|
||||
return key || null;
|
||||
}
|
||||
|
||||
function updateRowState() {
|
||||
paragraphRows.forEach((row, rowKey) => {
|
||||
const trigger = row.querySelector('[data-trigger-label]') as HTMLElement | null;
|
||||
const prompt = row.querySelector('[data-command-text]') as HTMLElement | null;
|
||||
const count = paragraphCounts.get(rowKey) || 0;
|
||||
const isActive = rowKey === activeParagraphKey;
|
||||
|
||||
row.classList.toggle('is-active', isActive);
|
||||
if (trigger) {
|
||||
trigger.textContent = countLabel(count);
|
||||
}
|
||||
if (prompt) {
|
||||
prompt.textContent = promptLabel(rowKey, isActive);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateSummaryFromCounts() {
|
||||
const paragraphCount = paragraphDescriptors.size;
|
||||
const discussedParagraphs = Array.from(paragraphCounts.values()).filter(count => count > 0).length;
|
||||
const approvedCount = Array.from(paragraphCounts.values()).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
if (paragraphCount === 0) {
|
||||
setSummaryMessage(t('paragraphComments.noParagraphs'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSummaryMessage(
|
||||
t('paragraphComments.summary', {
|
||||
paragraphCount,
|
||||
discussedCount: discussedParagraphs,
|
||||
approvedCount,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function createParagraphRow(key: string, excerpt: string) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'paragraph-comment-row';
|
||||
row.dataset.paragraphKey = key;
|
||||
row.innerHTML = `
|
||||
<div class="paragraph-comment-command">
|
||||
<span class="paragraph-comment-prompt">user@blog:~/articles$</span>
|
||||
<span class="paragraph-comment-command-text" data-command-text>${escapeHtml(promptLabel(key, false))}</span>
|
||||
</div>
|
||||
<div class="paragraph-comment-actions">
|
||||
<span class="paragraph-comment-hint" title="${escapeHtml(excerpt)}">${escapeHtml(t('paragraphComments.focusCurrent'))}</span>
|
||||
<button type="button" class="terminal-action-button paragraph-comment-trigger">
|
||||
<i class="fas fa-message"></i>
|
||||
<span data-trigger-label>${countLabel(0)}</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const button = row.querySelector('.paragraph-comment-trigger') as HTMLButtonElement | null;
|
||||
button?.addEventListener('click', () => {
|
||||
void openPanelForParagraph(key, { focusForm: true, syncHash: true });
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'paragraph-comment-panel terminal-panel hidden';
|
||||
panel.innerHTML = `
|
||||
<div class="paragraph-comment-panel-head">
|
||||
<div class="space-y-2">
|
||||
<span class="terminal-kicker">
|
||||
<i class="fas fa-terminal"></i>
|
||||
paragraph thread
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-[var(--title-color)]">${escapeHtml(t('paragraphComments.panelTitle'))}</h4>
|
||||
<p class="paragraph-comment-panel-excerpt text-sm leading-7 text-[var(--text-secondary)]"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paragraph-comment-panel-meta">
|
||||
<span class="terminal-chip" data-panel-count>${escapeHtml(t('paragraphComments.zeroNotes'))}</span>
|
||||
<span class="terminal-chip hidden" data-pending-count>0 ${escapeHtml(t('common.pending'))}</span>
|
||||
<button type="button" class="terminal-action-button" data-close-panel>
|
||||
<i class="fas fa-xmark"></i>
|
||||
<span>${escapeHtml(t('paragraphComments.close'))}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paragraph-comment-thread" data-thread></div>
|
||||
|
||||
<form class="paragraph-comment-form">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="terminal-form-label">
|
||||
${escapeHtml(t('paragraphComments.nickname'))} <span class="text-[var(--primary)]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="nickname"
|
||||
required
|
||||
placeholder="inline_operator"
|
||||
class="terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="terminal-form-label">
|
||||
${escapeHtml(t('paragraphComments.email'))} <span class="text-[var(--text-tertiary)] normal-case tracking-normal">(${escapeHtml(t('common.optional'))})</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="you@example.com"
|
||||
class="terminal-form-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="paragraph-comment-reply terminal-panel-muted hidden" data-reply-banner>
|
||||
<span class="text-sm text-[var(--text-secondary)]">
|
||||
${escapeHtml(t('paragraphComments.replyTo'))} -> <span class="font-medium text-[var(--primary)]" data-reply-target></span>
|
||||
</span>
|
||||
<button type="button" class="terminal-action-button" data-cancel-reply>
|
||||
<i class="fas fa-rotate-left"></i>
|
||||
<span>${escapeHtml(t('common.clear'))}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="terminal-form-label">
|
||||
${escapeHtml(t('paragraphComments.comment'))} <span class="text-[var(--primary)]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="content"
|
||||
required
|
||||
rows="5"
|
||||
maxlength="500"
|
||||
placeholder="${escapeHtml(t('paragraphComments.commentPlaceholder'))}"
|
||||
class="terminal-form-textarea resize-y"
|
||||
></textarea>
|
||||
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">${escapeHtml(t('paragraphComments.maxChars'))}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button type="submit" class="terminal-action-button terminal-action-button-primary">
|
||||
<i class="fas fa-paper-plane"></i>
|
||||
<span>${escapeHtml(t('common.submit'))}</span>
|
||||
</button>
|
||||
<button type="button" class="terminal-action-button" data-focus-paragraph>
|
||||
<i class="fas fa-crosshairs"></i>
|
||||
<span>${escapeHtml(t('paragraphComments.locateParagraph'))}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="paragraph-comment-status hidden" data-status></div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const panelExcerpt = panel.querySelector('.paragraph-comment-panel-excerpt') as HTMLElement;
|
||||
const panelCount = panel.querySelector('[data-panel-count]') as HTMLElement;
|
||||
const pendingCountChip = panel.querySelector('[data-pending-count]') as HTMLElement;
|
||||
const threadContainer = panel.querySelector('[data-thread]') as HTMLElement;
|
||||
const statusBox = panel.querySelector('[data-status]') as HTMLElement;
|
||||
const form = panel.querySelector('.paragraph-comment-form') as HTMLFormElement;
|
||||
const replyBanner = panel.querySelector('[data-reply-banner]') as HTMLElement;
|
||||
const replyTarget = panel.querySelector('[data-reply-target]') as HTMLElement;
|
||||
const focusButton = panel.querySelector('[data-focus-paragraph]') as HTMLButtonElement;
|
||||
|
||||
function clearStatus() {
|
||||
statusBox.className = 'paragraph-comment-status hidden';
|
||||
statusBox.textContent = '';
|
||||
}
|
||||
|
||||
function setStatus(message: string, tone: 'success' | 'error' | 'info') {
|
||||
statusBox.className = `paragraph-comment-status paragraph-comment-status-${tone}`;
|
||||
statusBox.textContent = message;
|
||||
}
|
||||
|
||||
function resetReplyState() {
|
||||
activeReplyToCommentId = null;
|
||||
replyBanner.classList.add('hidden');
|
||||
replyTarget.textContent = '';
|
||||
}
|
||||
|
||||
function setReplyState(commentId: number, author: string) {
|
||||
activeReplyToCommentId = commentId;
|
||||
replyBanner.classList.remove('hidden');
|
||||
replyTarget.textContent = author;
|
||||
}
|
||||
|
||||
function commentCardMarkup(
|
||||
comment: {
|
||||
id: number | string;
|
||||
author: string | null;
|
||||
content: string | null;
|
||||
created_at: string;
|
||||
reply_to_comment_id: number | null;
|
||||
},
|
||||
options?: {
|
||||
pending?: boolean;
|
||||
replyAuthor?: string | null;
|
||||
replyPreview?: string | null;
|
||||
}
|
||||
) {
|
||||
const author = comment.author || t('paragraphComments.anonymous');
|
||||
const pending = options?.pending || false;
|
||||
const replyAuthor = options?.replyAuthor;
|
||||
const replyPreview = options?.replyPreview;
|
||||
|
||||
return `
|
||||
<article class="paragraph-thread-item ${replyAuthor ? 'is-reply' : ''} ${pending ? 'is-pending' : ''}">
|
||||
<div class="paragraph-thread-head">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-semibold text-[var(--title-color)]">${escapeHtml(author)}</span>
|
||||
<span class="terminal-chip px-2 py-1 text-[10px]">
|
||||
<i class="far fa-clock text-[var(--primary)]"></i>
|
||||
${escapeHtml(formatCommentDate(comment.created_at))}
|
||||
</span>
|
||||
${
|
||||
pending
|
||||
? `<span class="terminal-chip px-2 py-1 text-[10px]">
|
||||
<i class="fas fa-hourglass-half text-[var(--warning)]"></i>
|
||||
${escapeHtml(t('paragraphComments.waitingReview'))}
|
||||
</span>`
|
||||
: ''
|
||||
}
|
||||
${
|
||||
replyAuthor
|
||||
? `<span class="terminal-chip px-2 py-1 text-[10px]">
|
||||
<i class="fas fa-reply text-[var(--primary)]"></i>
|
||||
${escapeHtml(replyAuthor)}
|
||||
</span>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
${
|
||||
replyPreview
|
||||
? `<p class="paragraph-thread-quote">${escapeHtml(replyPreview)}</p>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
${
|
||||
pending
|
||||
? ''
|
||||
: `<button
|
||||
type="button"
|
||||
class="terminal-action-button px-3 py-2 text-[11px]"
|
||||
data-reply-id="${comment.id}"
|
||||
data-reply-author="${escapeHtml(author)}"
|
||||
>
|
||||
<i class="fas fa-reply"></i>
|
||||
<span>${escapeHtml(t('common.reply'))}</span>
|
||||
</button>`
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">${escapeHtml(comment.content || '')}</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderThread(paragraphKey: string, comments: BrowserComment[]) {
|
||||
const pending = pendingComments.get(paragraphKey) || [];
|
||||
const authorById = new Map(comments.map(comment => [comment.id, comment.author || t('paragraphComments.anonymous')]));
|
||||
const contentById = new Map(comments.map(comment => [comment.id, comment.content || '']));
|
||||
|
||||
panelCount.textContent = countLabel(comments.length);
|
||||
paragraphCounts.set(paragraphKey, comments.length);
|
||||
pendingCountChip.textContent = `${pending.length} ${t('common.pending')}`;
|
||||
pendingCountChip.classList.toggle('hidden', pending.length === 0);
|
||||
updateRowState();
|
||||
updateSummaryFromCounts();
|
||||
|
||||
const approvedMarkup =
|
||||
comments.length === 0
|
||||
? `
|
||||
<div class="terminal-empty">
|
||||
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
|
||||
<span class="terminal-section-icon">
|
||||
<i class="fas fa-comment-slash"></i>
|
||||
</span>
|
||||
<h5 class="text-base font-semibold text-[var(--title-color)]">${escapeHtml(t('paragraphComments.emptyTitle'))}</h5>
|
||||
<p class="text-sm leading-7 text-[var(--text-secondary)]">
|
||||
${escapeHtml(t('paragraphComments.emptyDescription'))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: comments
|
||||
.map(comment =>
|
||||
commentCardMarkup(comment, {
|
||||
replyAuthor: comment.reply_to_comment_id
|
||||
? authorById.get(comment.reply_to_comment_id) || `#${comment.reply_to_comment_id}`
|
||||
: null,
|
||||
replyPreview: comment.reply_to_comment_id
|
||||
? previewReplyText(contentById.get(comment.reply_to_comment_id) || null)
|
||||
: null,
|
||||
})
|
||||
)
|
||||
.join('');
|
||||
|
||||
const pendingMarkup =
|
||||
pending.length === 0
|
||||
? ''
|
||||
: `
|
||||
<section class="paragraph-thread-segment">
|
||||
<div class="paragraph-thread-segment-label">${escapeHtml(t('paragraphComments.pendingQueue'))}</div>
|
||||
${pending
|
||||
.map(comment =>
|
||||
commentCardMarkup(comment, {
|
||||
pending: true,
|
||||
replyAuthor: comment.reply_to_comment_id
|
||||
? authorById.get(comment.reply_to_comment_id) || `#${comment.reply_to_comment_id}`
|
||||
: null,
|
||||
replyPreview: comment.reply_to_comment_id
|
||||
? previewReplyText(contentById.get(comment.reply_to_comment_id) || null)
|
||||
: null,
|
||||
})
|
||||
)
|
||||
.join('')}
|
||||
</section>
|
||||
`;
|
||||
|
||||
threadContainer.innerHTML = `
|
||||
<section class="paragraph-thread-segment">
|
||||
<div class="paragraph-thread-segment-label">${escapeHtml(t('paragraphComments.approvedThread'))}</div>
|
||||
${approvedMarkup}
|
||||
</section>
|
||||
${pendingMarkup}
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadThread(paragraphKey: string, forceReload = false) {
|
||||
if (!forceReload && threadCache.has(paragraphKey)) {
|
||||
return threadCache.get(paragraphKey) || [];
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${apiBase}/comments?${new URLSearchParams({
|
||||
post_slug: postSlug,
|
||||
scope: 'paragraph',
|
||||
paragraph_key: paragraphKey,
|
||||
approved: 'true',
|
||||
}).toString()}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const comments = (await response.json()) as BrowserComment[];
|
||||
threadCache.set(paragraphKey, comments);
|
||||
return comments;
|
||||
}
|
||||
|
||||
function syncHashForParagraph(key: string | null) {
|
||||
const nextUrl = new URL(window.location.href);
|
||||
nextUrl.hash = key ? anchorForParagraph(key) : '';
|
||||
history.replaceState(null, '', nextUrl.toString());
|
||||
}
|
||||
|
||||
async function openPanelForParagraph(
|
||||
paragraphKey: string,
|
||||
options?: {
|
||||
focusForm?: boolean;
|
||||
forceReload?: boolean;
|
||||
syncHash?: boolean;
|
||||
scrollIntoView?: boolean;
|
||||
}
|
||||
) {
|
||||
const descriptor = paragraphDescriptors.get(paragraphKey);
|
||||
const row = paragraphRows.get(paragraphKey);
|
||||
|
||||
if (!descriptor || !row) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeParagraphKey = paragraphKey;
|
||||
clearStatus();
|
||||
resetReplyState();
|
||||
panelExcerpt.textContent = descriptor.excerpt;
|
||||
row.insertAdjacentElement('afterend', panel);
|
||||
panel.classList.remove('hidden');
|
||||
panel.dataset.paragraphKey = paragraphKey;
|
||||
|
||||
paragraphDescriptors.forEach((item, key) => {
|
||||
item.element.classList.toggle('is-comment-focused', key === paragraphKey);
|
||||
});
|
||||
|
||||
if (options?.syncHash !== false) {
|
||||
syncHashForParagraph(paragraphKey);
|
||||
}
|
||||
|
||||
if (options?.scrollIntoView) {
|
||||
descriptor.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
updateRowState();
|
||||
threadContainer.innerHTML = `
|
||||
<div class="terminal-panel-muted text-sm text-[var(--text-secondary)]">
|
||||
${escapeHtml(t('paragraphComments.loadingThread'))}
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const comments = await loadThread(paragraphKey, options?.forceReload || false);
|
||||
renderThread(paragraphKey, comments);
|
||||
if (options?.focusForm) {
|
||||
form.querySelector('textarea')?.focus();
|
||||
}
|
||||
} catch (error) {
|
||||
panelCount.textContent = t('paragraphComments.loadFailedShort');
|
||||
pendingCountChip.classList.add('hidden');
|
||||
threadContainer.innerHTML = `
|
||||
<div class="paragraph-comment-status paragraph-comment-status-error">
|
||||
${escapeHtml(t('paragraphComments.loadFailed', { message: error instanceof Error ? error.message : 'unknown error' }))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function closePanel(clearHash = true) {
|
||||
activeParagraphKey = null;
|
||||
panel.classList.add('hidden');
|
||||
resetReplyState();
|
||||
clearStatus();
|
||||
paragraphDescriptors.forEach(item => item.element.classList.remove('is-comment-focused'));
|
||||
updateRowState();
|
||||
|
||||
if (clearHash) {
|
||||
syncHashForParagraph(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function openFromHash() {
|
||||
const paragraphKey = paragraphKeyFromHash(window.location.hash);
|
||||
if (!paragraphKey) {
|
||||
if (activeParagraphKey) {
|
||||
closePanel(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!paragraphDescriptors.has(paragraphKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openPanelForParagraph(paragraphKey, {
|
||||
focusForm: false,
|
||||
forceReload: activeParagraphKey === paragraphKey,
|
||||
syncHash: false,
|
||||
scrollIntoView: true,
|
||||
});
|
||||
}
|
||||
|
||||
panel.addEventListener('click', event => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
const replyButton = target?.closest('[data-reply-id]') as HTMLButtonElement | null;
|
||||
const closeButton = target?.closest('[data-close-panel]') as HTMLButtonElement | null;
|
||||
const cancelReplyButton = target?.closest('[data-cancel-reply]') as HTMLButtonElement | null;
|
||||
|
||||
if (replyButton) {
|
||||
const replyId = Number(replyButton.dataset.replyId || '0');
|
||||
const author = replyButton.dataset.replyAuthor || t('paragraphComments.anonymous');
|
||||
if (replyId > 0) {
|
||||
setReplyState(replyId, author);
|
||||
form.querySelector('textarea')?.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeButton) {
|
||||
closePanel(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancelReplyButton) {
|
||||
resetReplyState();
|
||||
}
|
||||
});
|
||||
|
||||
focusButton.addEventListener('click', () => {
|
||||
if (!activeParagraphKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptor = paragraphDescriptors.get(activeParagraphKey);
|
||||
descriptor?.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async event => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!activeParagraphKey) {
|
||||
setStatus(t('paragraphComments.selectedRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptor = paragraphDescriptors.get(activeParagraphKey);
|
||||
if (!descriptor) {
|
||||
setStatus(t('paragraphComments.contextMissing'), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData(form);
|
||||
clearStatus();
|
||||
setStatus(t('paragraphComments.submitting'), 'info');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/comments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
postSlug,
|
||||
nickname: formData.get('nickname'),
|
||||
email: formData.get('email'),
|
||||
content: formData.get('content'),
|
||||
scope: 'paragraph',
|
||||
paragraphKey: descriptor.key,
|
||||
paragraphExcerpt: descriptor.excerpt,
|
||||
replyToCommentId: activeReplyToCommentId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const pending = pendingComments.get(descriptor.key) || [];
|
||||
pendingCounter += 1;
|
||||
pending.push({
|
||||
id: `pending-${pendingCounter}`,
|
||||
author: String(formData.get('nickname') || t('paragraphComments.anonymous')),
|
||||
content: String(formData.get('content') || ''),
|
||||
created_at: new Date().toISOString(),
|
||||
reply_to_comment_id: activeReplyToCommentId,
|
||||
});
|
||||
pendingComments.set(descriptor.key, pending);
|
||||
|
||||
form.reset();
|
||||
resetReplyState();
|
||||
const approvedComments = await loadThread(descriptor.key, false);
|
||||
renderThread(descriptor.key, approvedComments);
|
||||
setStatus(t('paragraphComments.submitSuccess'), 'success');
|
||||
} catch (error) {
|
||||
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : 'unknown error' }), 'error');
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
if (!wrapper || !articleContent || !postSlug) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptors = buildParagraphDescriptors(articleContent);
|
||||
if (descriptors.length === 0) {
|
||||
setSummaryMessage(t('paragraphComments.noParagraphs'));
|
||||
return;
|
||||
}
|
||||
|
||||
descriptors.forEach(descriptor => {
|
||||
paragraphDescriptors.set(descriptor.key, descriptor);
|
||||
descriptor.element.id = `paragraph-${descriptor.key}`;
|
||||
descriptor.element.dataset.paragraphKey = descriptor.key;
|
||||
descriptor.element.classList.add('paragraph-comment-paragraph');
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiBase}/comments/paragraphs/summary?${new URLSearchParams({ post_slug: postSlug }).toString()}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
const summary = (await response.json()) as SummaryItem[];
|
||||
summary.forEach(item => {
|
||||
paragraphCounts.set(item.paragraph_key, item.count);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load paragraph comment summary:', error);
|
||||
}
|
||||
|
||||
descriptors.forEach(descriptor => {
|
||||
const row = createParagraphRow(descriptor.key, descriptor.excerpt);
|
||||
paragraphRows.set(descriptor.key, row);
|
||||
paragraphCounts.set(descriptor.key, paragraphCounts.get(descriptor.key) || 0);
|
||||
descriptor.element.insertAdjacentElement('afterend', row);
|
||||
});
|
||||
|
||||
updateRowState();
|
||||
updateSummaryFromCounts();
|
||||
await openFromHash();
|
||||
window.addEventListener('hashchange', () => {
|
||||
void openFromHash();
|
||||
});
|
||||
}
|
||||
|
||||
void init();
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Post } from '../lib/types';
|
||||
import TerminalButton from './ui/TerminalButton.astro';
|
||||
import CodeBlock from './CodeBlock.astro';
|
||||
import { formatReadTime, getI18n } from '../lib/i18n';
|
||||
import { resolveFileRef, getPostTypeColor } from '../lib/utils';
|
||||
|
||||
interface Props {
|
||||
@@ -11,6 +12,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { post, selectedTag = '', highlightTerm = '' } = Astro.props;
|
||||
const { locale, t } = getI18n(Astro);
|
||||
|
||||
const typeColor = getPostTypeColor(post.type);
|
||||
|
||||
@@ -43,20 +45,21 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
class="post-card terminal-panel group relative my-3 p-5 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
|
||||
style={`--post-border-color: ${typeColor}`}
|
||||
>
|
||||
<a href={`/articles/${post.slug}`} class="absolute inset-0 z-0 rounded-[inherit]" aria-label={`阅读 ${post.title}`}></a>
|
||||
<div class="absolute left-0 top-4 bottom-4 w-1 rounded-full opacity-80" style={`background-color: ${typeColor}`}></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between mb-2 pl-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="w-3 h-3 rounded-full shrink-0" style={`background-color: ${typeColor}`}></span>
|
||||
<h3
|
||||
class={`font-bold text-[var(--title-color)] ${post.type === 'article' ? 'text-lg' : 'text-base'}`}
|
||||
set:html={highlightText(post.title, highlightTerm)}
|
||||
/>
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
class={`inline-flex min-w-0 items-center text-[var(--title-color)] transition hover:text-[var(--primary)] ${post.type === 'article' ? 'text-lg font-bold' : 'text-base font-bold'}`}
|
||||
>
|
||||
<h3 class="truncate" set:html={highlightText(post.title, highlightTerm)} />
|
||||
</a>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{post.date} | 阅读时间: {post.readTime}
|
||||
{post.date} | {t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}
|
||||
</p>
|
||||
</div>
|
||||
<span class="terminal-chip shrink-0 text-xs py-1 px-2.5">
|
||||
@@ -109,4 +112,14 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||
</TerminalButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 mt-4 pl-3">
|
||||
<a
|
||||
href={`/articles/${post.slug}`}
|
||||
class="terminal-action-button inline-flex"
|
||||
>
|
||||
<i class="fas fa-angle-right"></i>
|
||||
<span>{t('common.readMore')}</span>
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
import { apiClient } from '../lib/api/client';
|
||||
import { getI18n } from '../lib/i18n';
|
||||
|
||||
interface Props {
|
||||
currentSlug: string;
|
||||
@@ -8,6 +9,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const { currentSlug, currentCategory, currentTags } = Astro.props;
|
||||
const { t } = getI18n(Astro);
|
||||
|
||||
const allPosts = await apiClient.getPosts();
|
||||
|
||||
@@ -43,9 +45,9 @@ const relatedPosts = allPosts
|
||||
<i class="fas fa-share-nodes"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">相关文章</h3>
|
||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('relatedPosts.title')}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
基于当前分类与标签关联出的相近内容,延续同一条阅读链路。
|
||||
{t('relatedPosts.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,7 +55,7 @@ const relatedPosts = allPosts
|
||||
|
||||
<span class="terminal-stat-pill">
|
||||
<i class="fas fa-wave-square text-[var(--primary)]"></i>
|
||||
{relatedPosts.length} linked
|
||||
{t('relatedPosts.linked', { count: relatedPosts.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
---
|
||||
// Table of Contents Component - Extracts headings from article content
|
||||
import { getI18n } from '../lib/i18n';
|
||||
|
||||
const { t } = getI18n(Astro);
|
||||
---
|
||||
|
||||
<aside id="toc-container" class="hidden w-full shrink-0 lg:block lg:w-72">
|
||||
@@ -14,9 +17,9 @@
|
||||
<i class="fas fa-list-ul"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-[var(--title-color)]">目录</h3>
|
||||
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3>
|
||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
||||
实时跟踪当前文档的标题节点,像终端侧栏一样快速跳转。
|
||||
{t('toc.intro')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user