chore: checkpoint ai search comments and i18n foundation

This commit is contained in:
2026-03-28 17:17:31 +08:00
parent d18a709987
commit ec96d91548
71 changed files with 9494 additions and 423 deletions

View File

@@ -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);
}

View File

@@ -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');
}
});

View File

@@ -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)]">&copy; {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"

View File

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

View File

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

View File

@@ -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)

View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
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>

View File

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

View File

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

View File

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

16
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="astro/client" />
declare global {
interface Window {
__TERMI_I18N__?: {
locale: string;
messages: Record<string, unknown>;
};
__termiTranslate: (
key: string,
params?: Record<string, string | number | null | undefined>
) => string;
}
}
export {};

View File

@@ -4,6 +4,7 @@ import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import BackToTop from '../components/interactive/BackToTop.svelte';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { getI18n, LOCALE_COOKIE_NAME, SUPPORTED_LOCALES } from '../lib/i18n';
interface Props {
title?: string;
@@ -11,6 +12,7 @@ interface Props {
}
const props = Astro.props;
const { locale, messages } = getI18n(Astro);
let siteSettings = DEFAULT_SITE_SETTINGS;
@@ -22,10 +24,11 @@ try {
const title = props.title || siteSettings.siteTitle;
const description = props.description || siteSettings.siteDescription;
const i18nPayload = JSON.stringify({ locale, messages });
---
<!DOCTYPE html>
<html lang="zh-CN">
<html lang={locale} data-locale={locale}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -162,6 +165,32 @@ const description = props.description || siteSettings.siteDescription;
}
</style>
<script is:inline define:vars={{ i18nPayload, locale, localeCookieName: LOCALE_COOKIE_NAME, supportedLocales: SUPPORTED_LOCALES }}>
window.__TERMI_I18N__ = JSON.parse(i18nPayload);
window.__termiTranslate = function(key, params = {}) {
const payload = window.__TERMI_I18N__ || { messages: {} };
const template = key.split('.').reduce((current, segment) => {
if (!current || typeof current !== 'object') {
return undefined;
}
return current[segment];
}, payload.messages);
if (typeof template !== 'string') {
return key;
}
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''));
};
(function() {
document.documentElement.lang = locale;
document.documentElement.dataset.locale = locale;
localStorage.setItem('locale', locale);
document.cookie = `${localeCookieName}=${encodeURIComponent(locale)};path=/;max-age=31536000;samesite=lax`;
})();
</script>
<script is:inline>
(function() {
const theme = localStorage.getItem('theme');

View File

@@ -32,6 +32,10 @@ export interface Comment {
avatar: string | null;
content: string | null;
reply_to: string | null;
reply_to_comment_id: number | null;
scope: 'article' | 'paragraph';
paragraph_key: string | null;
paragraph_excerpt: string | null;
approved: boolean | null;
created_at: string;
updated_at: string;
@@ -42,7 +46,16 @@ export interface CreateCommentInput {
nickname: string;
email?: string;
content: string;
scope?: 'article' | 'paragraph';
paragraphKey?: string;
paragraphExcerpt?: string;
replyTo?: string | null;
replyToCommentId?: number | null;
}
export interface ParagraphCommentSummary {
paragraph_key: string;
count: number;
}
export interface ApiTag {
@@ -98,6 +111,23 @@ export interface ApiSiteSettings {
social_email: string | null;
location: string | null;
tech_stack: string[] | null;
ai_enabled: boolean;
}
export interface AiSource {
slug: string;
title: string;
excerpt: string;
score: number;
chunk_index: number;
}
export interface AiAskResponse {
question: string;
answer: string;
sources: AiSource[];
indexed_chunks: number;
last_indexed_at: string | null;
}
export interface ApiSearchResult {
@@ -153,6 +183,9 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
email: 'mailto:hello@termi.dev',
},
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
ai: {
enabled: false,
},
};
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
@@ -244,6 +277,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
ai: {
enabled: Boolean(settings.ai_enabled),
},
});
class ApiClient {
@@ -293,14 +329,32 @@ class ApiClient {
return posts.find(post => post.slug === slug) || null;
}
async getComments(postSlug: string, options?: { approved?: boolean }): Promise<Comment[]> {
async getComments(
postSlug: string,
options?: {
approved?: boolean;
scope?: 'article' | 'paragraph';
paragraphKey?: string;
}
): Promise<Comment[]> {
const params = new URLSearchParams({ post_slug: postSlug });
if (options?.approved !== undefined) {
params.set('approved', String(options.approved));
}
if (options?.scope) {
params.set('scope', options.scope);
}
if (options?.paragraphKey) {
params.set('paragraph_key', options.paragraphKey);
}
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
}
async getParagraphCommentSummary(postSlug: string): Promise<ParagraphCommentSummary[]> {
const params = new URLSearchParams({ post_slug: postSlug });
return this.fetch<ParagraphCommentSummary[]>(`/comments/paragraphs/summary?${params.toString()}`);
}
async createComment(comment: CreateCommentInput): Promise<Comment> {
return this.fetch<Comment>('/comments', {
method: 'POST',
@@ -309,7 +363,11 @@ class ApiClient {
nickname: comment.nickname,
email: comment.email,
content: comment.content,
scope: comment.scope,
paragraphKey: comment.paragraphKey,
paragraphExcerpt: comment.paragraphExcerpt,
replyTo: comment.replyTo,
replyToCommentId: comment.replyToCommentId,
}),
});
}
@@ -398,6 +456,13 @@ class ApiClient {
})
);
}
async askAi(question: string): Promise<AiAskResponse> {
return this.fetch<AiAskResponse>('/ai/ask', {
method: 'POST',
body: JSON.stringify({ question }),
});
}
}
export const api = new ApiClient(API_BASE_URL);

View File

@@ -0,0 +1,144 @@
import { messages } from './messages';
export const SUPPORTED_LOCALES = ['zh-CN', 'en'] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'zh-CN';
export const LOCALE_COOKIE_NAME = 'termi_locale';
type TranslateParams = Record<string, string | number | null | undefined>;
function resolveMessage(locale: Locale, key: string): string | undefined {
const segments = key.split('.');
let current: unknown = messages[locale];
for (const segment of segments) {
if (!current || typeof current !== 'object' || !(segment in current)) {
current = undefined;
break;
}
current = (current as Record<string, unknown>)[segment];
}
return typeof current === 'string' ? current : undefined;
}
function interpolate(template: string, params?: TranslateParams): string {
if (!params) {
return template;
}
return template.replace(/\{(\w+)\}/g, (_, name) => String(params[name] ?? ''));
}
export function normalizeLocale(value: string | null | undefined): Locale | null {
if (!value) {
return null;
}
const normalized = value.trim().toLowerCase();
if (!normalized) {
return null;
}
if (normalized.startsWith('zh')) {
return 'zh-CN';
}
if (normalized.startsWith('en')) {
return 'en';
}
return null;
}
export function resolveLocale(options: {
query?: string | null;
cookie?: string | null;
acceptLanguage?: string | null;
}): Locale {
const fromQuery = normalizeLocale(options.query);
if (fromQuery) {
return fromQuery;
}
const fromCookie = normalizeLocale(options.cookie);
if (fromCookie) {
return fromCookie;
}
const acceptLanguages = String(options.acceptLanguage || '')
.split(',')
.map((part) => normalizeLocale(part.split(';')[0]))
.filter(Boolean) as Locale[];
return acceptLanguages[0] || DEFAULT_LOCALE;
}
export function translate(locale: Locale, key: string, params?: TranslateParams): string {
const template =
resolveMessage(locale, key) ??
resolveMessage(DEFAULT_LOCALE, key) ??
key;
return interpolate(template, params);
}
export function getMessages(locale: Locale) {
return messages[locale];
}
export function buildLocaleUrl(url: URL, locale: Locale): string {
const next = new URL(url);
next.searchParams.set('lang', locale);
return `${next.pathname}${next.search}${next.hash}`;
}
export function getI18n(Astro: {
url: URL;
request: Request;
cookies?: {
get?: (name: string) => { value: string } | undefined;
};
}) {
const requestUrl = new URL(Astro.request.url);
const locale = resolveLocale({
query: requestUrl.searchParams.get('lang'),
cookie: Astro.cookies?.get?.(LOCALE_COOKIE_NAME)?.value ?? null,
acceptLanguage: Astro.request.headers.get('accept-language'),
});
const t = (key: string, params?: TranslateParams) => translate(locale, key, params);
return {
locale,
t,
messages: getMessages(locale),
buildLocaleUrl: (targetLocale: Locale) => buildLocaleUrl(requestUrl, targetLocale),
};
}
export function getReadTimeMinutes(value: string | number | null | undefined): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
const match = String(value || '').match(/\d+/);
if (!match) {
return null;
}
return Number(match[0]);
}
export function formatReadTime(
_locale: Locale,
value: string | number | null | undefined,
t: (key: string, params?: TranslateParams) => string
): string {
const minutes = getReadTimeMinutes(value);
if (minutes === null) {
return String(value || '');
}
return t('common.readTimeMinutes', { count: minutes });
}

View File

@@ -0,0 +1,696 @@
export const messages = {
'zh-CN': {
common: {
language: '语言',
languages: {
'zh-CN': '简体中文',
en: 'English',
},
all: '全部',
search: '搜索',
ai: 'AI',
article: '文章',
tweet: '动态',
posts: '文章',
tags: '标签',
categories: '分类',
friends: '友链',
location: '位置',
unknown: '未知',
other: '其他',
current: '当前',
readTime: '阅读时间',
readTimeMinutes: '{count} 分钟',
characters: '{count} 字',
postsCount: '{count} 篇',
tagsCount: '{count} 个标签',
categoriesCount: '{count} 个分类',
friendsCount: '{count} 个友链',
reviewsCount: '{count} 条评价',
resultsCount: '{count} 条结果',
reviewedOnly: '仅展示已通过审核',
noData: '暂无数据',
noResults: '没有匹配结果',
open: '打开',
close: '关闭',
submit: '提交',
cancel: '取消',
clear: '清除',
reset: '重置',
reply: '回复',
like: '点赞',
visit: '访问',
readMore: '阅读全文',
viewArticle: '打开文章',
viewAllArticles: '查看所有文章',
viewAllLinks: '查看全部友链',
viewCategoryArticles: '查看分类文章',
clearFilters: '清除筛选',
resetFilters: '重置筛选',
home: '首页',
browsePosts: '浏览文章',
goBack: '返回上一页',
backToIndex: '返回索引',
copyPermalink: '复制固定链接',
locateParagraph: '定位段落',
maxChars: '最多 {count} 字',
optional: '可选',
pending: '待审核',
approved: '已审核',
completed: '已完成',
inProgress: '进行中',
featureOn: '功能已开启',
featureOff: '功能未开启',
emptyState: '当前还没有内容。',
apiUnavailable: 'API 暂时不可用',
},
nav: {
articles: '文章',
categories: '分类',
tags: '标签',
timeline: '时间轴',
reviews: '评价',
friends: '友链',
about: '关于',
ask: 'AI 问答',
},
header: {
navigation: '导航',
themeToggle: '切换主题',
toggleMenu: '切换菜单',
searchModeKeyword: '搜索',
searchModeAi: 'AI',
searchModeKeywordMobile: '关键词搜索',
searchModeAiMobile: 'AI 搜索',
searchPlaceholderKeyword: "'关键词'",
searchPlaceholderAi: '输入问题,交给站内 AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: '手动确认',
aiModeTitle: 'AI 问答模式',
aiModeHeading: '把这个问题交给站内 AI',
aiModeDescription: 'AI 会先检索站内知识库,再给出总结式回答,并附带相关文章来源。',
aiModeNotice: '进入问答页后不会自动调用模型,需要你手动确认发送。',
aiModeCta: '前往 AI 问答页确认',
liveResults: '实时搜索结果',
searching: '正在搜索 {query} ...',
searchFailed: '搜索失败,请稍后再试。',
searchEmpty: '没有找到和 {query} 相关的内容。',
searchEmptyCta: '去 AI 问答页确认提问',
searchAiFooter: '去 AI 问答页手动确认',
searchAllResults: '查看全部搜索结果',
untitled: '未命名',
manualConfirm: 'AI 必须手动确认后才会提问',
},
footer: {
session: '会话',
copyright: '© {year} {site}. 保留所有权利。',
sitemap: '站点地图',
rss: 'RSS 订阅',
},
home: {
pinned: '置顶',
about: '关于我',
techStack: '技术栈',
systemStatus: '系统状态',
},
articlesPage: {
title: '文章索引',
description: '按类型、分类和标签筛选内容,快速浏览整个内容目录。',
totalPosts: '共 {count} 篇',
allCategories: '全部分类',
allTags: '全部标签',
emptyTitle: '没有匹配结果',
emptyDescription: '当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。',
pageSummary: '第 {current} / {total} 页 · 共 {count} 条结果',
previous: '上一页',
next: '下一页',
},
article: {
backToArticles: '返回文章索引',
documentSession: '文档会话',
filePath: '文件路径',
},
relatedPosts: {
title: '相关文章',
description: '基于当前分类与标签关联出的相近内容,延续同一条阅读链路。',
linked: '{count} 条关联',
},
comments: {
title: '评论终端',
description: '这里是整篇文章的讨论区,当前缓冲区共有 {count} 条已展示评论,新的留言提交后会进入审核队列。',
writeComment: '写评论',
nickname: '昵称',
email: '邮箱',
message: '内容',
messagePlaceholder: "$ echo '留下你的想法...'",
maxChars: '最多 500 字',
cancelReply: '取消回复',
emptyTitle: '暂无评论',
emptyDescription: '当前还没有留言。可以打开上面的输入面板,成为第一个在这个终端缓冲区里发言的人。',
today: '今天',
yesterday: '昨天',
daysAgo: '{count} 天前',
weeksAgo: '{count} 周前',
anonymous: '匿名',
submitting: '正在提交评论...',
submitSuccess: '评论已提交,审核通过后会显示在这里。',
submitFailed: '提交失败:{message}',
loadFailed: '加载评论失败',
noSelection: '当前没有选中的评论。',
},
paragraphComments: {
title: '段落评论已启用',
intro: '正文里的自然段都会挂一个轻量讨论入口,适合只针对某一段补充上下文、指出问题或继续展开讨论。',
scanning: '正在扫描段落缓冲区...',
noParagraphs: '当前文章没有可挂载评论的自然段。',
summary: '已为 {paragraphCount} 个自然段挂载评论入口,其中 {discussedCount} 段已有讨论,当前共展示 {approvedCount} 条已审核段落评论。',
focusCurrent: '聚焦当前段落',
panelTitle: '段落讨论面板',
close: '关闭',
nickname: '昵称',
email: '邮箱',
comment: '评论',
commentPlaceholder: "$ echo '只评论这一段...'",
maxChars: '最多 500 字',
clearReply: '清除回复',
replyTo: '回复给',
approvedThread: '公开讨论',
pendingQueue: '待审核队列',
emptyTitle: '这段还没有公开评论',
emptyDescription: '适合补充上下文、指出细节或者提出具体问题。新的留言提交后会先进入审核队列。',
loadingThread: '正在拉取该段的已审核评论...',
loadFailedShort: '加载失败',
loadFailed: '加载失败:{message}',
selectedRequired: '当前没有选中的段落。',
contextMissing: '段落上下文丢失,请重新打开该段评论面板。',
submitting: '正在提交段落评论...',
submitSuccess: '评论已提交,已先放入本地待审核队列;审核通过后会进入公开讨论。',
submitFailed: '提交失败:{message}',
anonymous: '匿名',
oneNote: '1 条评论',
manyNotes: '{count} 条评论',
zeroNotes: '评论',
waitingReview: '等待审核',
locateParagraph: '定位段落',
},
ask: {
pageTitle: 'AI 问答',
pageDescription: '基于 {siteName} 内容知识库的站内 AI 问答',
title: 'AI 站内问答',
subtitle: '基于博客 Markdown 内容建立索引,回答会优先引用站内真实资料。',
disabledTitle: '后台暂未开启 AI 问答',
disabledDescription: '这个入口已经接好了真实后端,但当前站点设置里没有开启公开问答。管理员开启后,这里会自动变成可用状态,导航也会同步显示。',
textareaPlaceholder: '输入你想问的问题,比如:这个博客关于前端写过哪些内容?',
submit: '开始提问',
idleStatus: '知识库已接入,等待问题输入。',
examples: '示例问题',
workflow: '工作流',
workflow1: '1. 后台开启 AI 开关并配置聊天模型。',
workflow2: '2. 重建索引,把 Markdown 文章切块后由后端本地生成 embedding并写入 PostgreSQL pgvector。',
workflow3: '3. 前台提问时先在 pgvector 中做相似度检索,再交给聊天模型基于上下文回答。',
emptyAnswer: '暂无回答。',
requestFailed: '请求失败:{message}',
streamUnsupported: '当前浏览器无法读取流式响应。',
enterQuestion: '先输入一个问题。',
cacheRestored: '已从当前会话缓存中恢复回答。',
connecting: '正在建立流式连接,请稍候...',
processing: '正在处理请求...',
complete: '回答已生成。',
streamFailed: '流式请求失败',
streamInterrupted: '流式响应被提前中断。',
retryLater: '这次请求没有成功,可以稍后重试。',
prefixedQuestion: '已带入搜索词,确认后开始提问。',
sources: '来源',
},
about: {
pageTitle: '关于',
title: '关于我',
intro: '这里汇总站点主人、技术栈、系统状态和联系方式,并与全站语言设置保持一致。',
techStackCount: '{count} 项技术栈',
profile: '身份档案',
contact: '联系方式',
website: '网站',
},
categories: {
pageTitle: '分类',
title: '文章分类',
intro: '按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。',
quickJump: '快速跳转分类文章',
categoryPosts: '浏览 {name} 主题下的全部文章和更新记录。',
empty: '暂无分类数据',
},
friends: {
pageTitle: '友情链接',
pageDescription: '与 {siteName} 交换友情链接',
title: '友情链接',
intro: '这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。',
collection: '友链分组',
exchangeRules: '友链交换',
exchangeIntro: '欢迎交换友情链接,请确保您的网站满足以下条件:',
rule1: '原创内容为主',
rule2: '网站稳定运行',
rule3: '无不良内容',
siteInfo: '本站信息:',
name: '名称',
description: '描述',
link: '链接',
},
friendForm: {
title: '提交友链申请',
intro: '填写站点信息后会提交到后台审核,审核通过后前台会自动展示。',
reviewedOnline: '后台审核后上线',
siteName: '站点名称',
siteUrl: '站点链接',
avatarUrl: '头像链接',
category: '分类',
description: '站点描述',
reciprocal: '已添加本站友链',
reciprocalHint: '这是提交申请前的必要条件。',
copy: '复制',
copied: '已复制',
submit: '提交申请',
reset: '重置',
addReciprocalFirst: '请先添加本站友链后再提交申请。',
submitting: '正在提交友链申请...',
submitSuccess: '友链申请已提交,我们会尽快审核。',
submitFailed: '提交失败:{message}',
categoryTech: '技术',
categoryLife: '生活',
categoryDesign: '设计',
categoryOther: '其他',
descriptionPlaceholder: '简要介绍一下你的网站...',
},
friendCard: {
externalLink: '外部链接',
},
tags: {
pageTitle: '标签',
title: '标签云',
intro: '用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。',
currentTag: '当前: #{tag}',
selectedSummary: '标签 #{tag} 找到 {count} 篇文章',
browseTags: '浏览标签',
emptyTags: '暂无标签数据',
emptyPosts: '没有找到该标签的文章',
},
timeline: {
pageTitle: '时间轴',
pageDescription: '记录 {ownerName} 的技术成长与生活点滴',
title: '时间轴',
subtitle: '共 {count} 篇内容 · 记录 {ownerName} 的技术成长与生活点滴',
allYears: '全部',
},
reviews: {
pageTitle: '评价',
pageDescription: '记录游戏、音乐、动画、书籍与影视的体验和评价',
title: '评价',
subtitle: '记录游戏、音乐、动画、书籍与影视的体验和感悟',
total: '总评价',
average: '平均评分',
completed: '已完成',
inProgress: '进行中',
emptyData: '暂无评价数据,请检查后端 API 连接',
emptyFiltered: '当前筛选下暂无评价',
currentFilter: '当前筛选: {type}',
typeAll: '全部',
typeGame: '游戏',
typeAnime: '动画',
typeMusic: '音乐',
typeBook: '书籍',
typeMovie: '影视',
},
notFound: {
pageTitle: '页面未找到',
pageDescription: '您访问的页面不存在',
title: '404 - 页面未找到',
intro: '当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。',
terminalLog: '终端错误日志',
requestedRouteNotFound: '错误:请求的路由不存在',
path: '路径',
time: '时间',
actions: '可执行操作',
actionsIntro: '像命令面板一样,优先给出直接可走的恢复路径。',
searchHint: '也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。',
recommended: '推荐入口',
recommendedIntro: '使用真实文章数据,避免 404 页面再把人带进不存在的地址。',
cannotLoad: '暂时无法读取文章列表。',
},
toc: {
title: '目录',
intro: '实时跟踪当前文档的标题节点,像终端侧栏一样快速跳转。',
},
codeCopy: {
copy: '复制',
copied: '已复制',
failed: '失败',
},
},
en: {
common: {
language: 'Language',
languages: {
'zh-CN': '简体中文',
en: 'English',
},
all: 'All',
search: 'Search',
ai: 'AI',
article: 'Article',
tweet: 'Update',
posts: 'Posts',
tags: 'Tags',
categories: 'Categories',
friends: 'Links',
location: 'Location',
unknown: 'Unknown',
other: 'Other',
current: 'Current',
readTime: 'Read time',
readTimeMinutes: '{count} min read',
characters: '{count} chars',
postsCount: '{count} posts',
tagsCount: '{count} tags',
categoriesCount: '{count} categories',
friendsCount: '{count} links',
reviewsCount: '{count} reviews',
resultsCount: '{count} results',
reviewedOnly: 'Approved links only',
noData: 'No data yet',
noResults: 'No matching results',
open: 'Open',
close: 'Close',
submit: 'Submit',
cancel: 'Cancel',
clear: 'Clear',
reset: 'Reset',
reply: 'Reply',
like: 'Like',
visit: 'Visit',
readMore: 'Read more',
viewArticle: 'Open article',
viewAllArticles: 'View all articles',
viewAllLinks: 'View all links',
viewCategoryArticles: 'View category posts',
clearFilters: 'Clear filters',
resetFilters: 'Reset filters',
home: 'Home',
browsePosts: 'Browse posts',
goBack: 'Go back',
backToIndex: 'Back to index',
copyPermalink: 'Copy permalink',
locateParagraph: 'Locate paragraph',
maxChars: 'Max {count} chars',
optional: 'Optional',
pending: 'Pending',
approved: 'Approved',
completed: 'Completed',
inProgress: 'In progress',
featureOn: 'Feature on',
featureOff: 'Feature off',
emptyState: 'Nothing here yet.',
apiUnavailable: 'API temporarily unavailable',
},
nav: {
articles: 'Articles',
categories: 'Categories',
tags: 'Tags',
timeline: 'Timeline',
reviews: 'Reviews',
friends: 'Links',
about: 'About',
ask: 'Ask AI',
},
header: {
navigation: 'Navigation',
themeToggle: 'Toggle theme',
toggleMenu: 'Toggle menu',
searchModeKeyword: 'Search',
searchModeAi: 'AI',
searchModeKeywordMobile: 'Keyword Search',
searchModeAiMobile: 'AI Search',
searchPlaceholderKeyword: "'keyword'",
searchPlaceholderAi: 'Type a question for the site AI',
searchHintKeyword: 'articles/*.md',
searchHintAi: 'manual confirm',
aiModeTitle: 'AI Q&A mode',
aiModeHeading: 'Send this question to the site AI',
aiModeDescription: 'The AI will search the site knowledge base first, then answer with source-backed summaries.',
aiModeNotice: 'The model will not run automatically after navigation. You must confirm manually.',
aiModeCta: 'Open AI Q&A to confirm',
liveResults: 'Live results',
searching: 'Searching {query} ...',
searchFailed: 'Search failed. Please try again later.',
searchEmpty: 'No content matched {query}.',
searchEmptyCta: 'Ask AI instead',
searchAiFooter: 'Open AI Q&A manually',
searchAllResults: 'View all results',
untitled: 'Untitled',
manualConfirm: 'AI questions must be confirmed manually',
},
footer: {
session: 'Session',
copyright: '© {year} {site}. All rights reserved.',
sitemap: 'Sitemap',
rss: 'RSS feed',
},
home: {
pinned: 'Pinned',
about: 'About',
techStack: 'Tech stack',
systemStatus: 'System status',
},
articlesPage: {
title: 'Article Index',
description: 'Filter content by type, category, and tag to browse the full archive quickly.',
totalPosts: '{count} posts',
allCategories: 'All categories',
allTags: 'All tags',
emptyTitle: 'No matching results',
emptyDescription: 'No posts matched the current filters. Clear a tag or keyword to browse the full archive again.',
pageSummary: 'Page {current}/{total} · {count} results',
previous: 'Prev',
next: 'Next',
},
article: {
backToArticles: 'Back to article index',
documentSession: 'Document session',
filePath: 'File path',
},
relatedPosts: {
title: 'Related Posts',
description: 'More nearby reading paths based on the current category and shared tags.',
linked: '{count} linked',
},
comments: {
title: 'Comment Terminal',
description: 'This is the discussion thread for the whole article. {count} approved comments are shown right now, and new messages enter moderation first.',
writeComment: 'Write comment',
nickname: 'Nickname',
email: 'Email',
message: 'Message',
messagePlaceholder: "$ echo 'Leave your thoughts here...'",
maxChars: 'Max 500 chars',
cancelReply: 'Cancel reply',
emptyTitle: 'No comments yet',
emptyDescription: 'No one has posted here yet. Open the input panel above and be the first voice in this buffer.',
today: 'Today',
yesterday: 'Yesterday',
daysAgo: '{count} days ago',
weeksAgo: '{count} weeks ago',
anonymous: 'Anonymous',
submitting: 'Submitting comment...',
submitSuccess: 'Comment submitted. It will appear here after moderation.',
submitFailed: 'Submit failed: {message}',
loadFailed: 'Failed to load comments',
noSelection: 'No comment is selected.',
},
paragraphComments: {
title: 'Paragraph comments are enabled',
intro: 'Each natural paragraph in the article gets a lightweight discussion entry point, perfect for focused context, corrections, or follow-up questions.',
scanning: 'Scanning paragraph buffer...',
noParagraphs: 'No commentable paragraphs were found in this article.',
summary: '{paragraphCount} paragraphs have comment entries, {discussedCount} already have discussion, and {approvedCount} approved paragraph comments are currently visible.',
focusCurrent: 'Focus current paragraph',
panelTitle: 'Paragraph discussion panel',
close: 'Close',
nickname: 'Nickname',
email: 'Email',
comment: 'Comment',
commentPlaceholder: "$ echo 'Comment on this paragraph only...'",
maxChars: 'Max 500 chars',
clearReply: 'Clear reply',
replyTo: 'Reply to',
approvedThread: 'Approved thread',
pendingQueue: 'Pending queue',
emptyTitle: 'No public comments on this paragraph yet',
emptyDescription: 'Use this space to add context, point out details, or ask a specific question. New comments go through moderation first.',
loadingThread: 'Loading approved comments for this paragraph...',
loadFailedShort: 'Load failed',
loadFailed: 'Load failed: {message}',
selectedRequired: 'No paragraph is currently selected.',
contextMissing: 'Paragraph context was lost. Please reopen the paragraph panel.',
submitting: 'Submitting paragraph comment...',
submitSuccess: 'Comment submitted. It has been placed into the local pending queue and will join the public thread after moderation.',
submitFailed: 'Submit failed: {message}',
anonymous: 'Anonymous',
oneNote: '1 note',
manyNotes: '{count} notes',
zeroNotes: 'comment',
waitingReview: 'waiting review',
locateParagraph: 'Locate paragraph',
},
ask: {
pageTitle: 'Ask AI',
pageDescription: 'An on-site AI Q&A experience grounded in the {siteName} knowledge base',
title: 'On-site AI Q&A',
subtitle: 'Answers are grounded in indexed Markdown content from the blog and prioritize real on-site references.',
disabledTitle: 'AI Q&A is not enabled yet',
disabledDescription: 'The real backend integration is already in place, but public Q&A is still disabled in site settings. Once it is enabled, this page and the navigation entry will become available automatically.',
textareaPlaceholder: 'Ask anything, for example: what has this blog written about frontend topics?',
submit: 'Ask now',
idleStatus: 'Knowledge base connected. Waiting for a question.',
examples: 'Example questions',
workflow: 'Workflow',
workflow1: '1. Enable the AI switch in the admin and configure the chat model.',
workflow2: '2. Rebuild the index so Markdown content is chunked, embedded locally by the backend, and written into PostgreSQL pgvector.',
workflow3: '3. Each user question retrieves similar chunks from pgvector first, then the chat model answers with that context.',
emptyAnswer: 'No answer yet.',
requestFailed: 'Request failed: {message}',
streamUnsupported: 'This browser cannot read streaming responses.',
enterQuestion: 'Enter a question first.',
cacheRestored: 'Restored the answer from the current session cache.',
connecting: 'Opening stream connection...',
processing: 'Processing request...',
complete: 'Answer generated.',
streamFailed: 'Streaming request failed',
streamInterrupted: 'The streaming response ended early.',
retryLater: 'This request did not complete successfully. Please try again later.',
prefixedQuestion: 'The search query has been prefilled. Confirm manually to ask AI.',
sources: 'Sources',
},
about: {
pageTitle: 'About',
title: 'About',
intro: 'This page gathers the site owner profile, tech stack, system stats, and contact details while following the same language setting as the rest of the site.',
techStackCount: '{count} tech items',
profile: 'Profile',
contact: 'Contact',
website: 'Website',
},
categories: {
pageTitle: 'Categories',
title: 'Categories',
intro: 'Browse posts by topic. This page now follows the same terminal language as the other list views.',
quickJump: 'Jump straight into category posts',
categoryPosts: 'Browse all posts and updates under {name}.',
empty: 'No category data yet',
},
friends: {
pageTitle: 'Links',
pageDescription: 'Exchange links with {siteName}',
title: 'Friend Links',
intro: 'This page gathers approved sites and keeps the application panel in the same visual language so the list and form feel like one screen.',
collection: 'Friend collection',
exchangeRules: 'Link exchange',
exchangeIntro: 'You are welcome to exchange links. Please make sure your site meets these conditions:',
rule1: 'Original content as the main focus',
rule2: 'Stable uptime',
rule3: 'No harmful content',
siteInfo: 'Site info:',
name: 'Name',
description: 'Description',
link: 'Link',
},
friendForm: {
title: 'Submit a link request',
intro: 'Fill in your site info and it will be sent to the moderation queue. Once approved, it will appear on the frontend automatically.',
reviewedOnline: 'Published after review',
siteName: 'Site name',
siteUrl: 'Site URL',
avatarUrl: 'Avatar URL',
category: 'Category',
description: 'Site description',
reciprocal: 'I already added this site to my links',
reciprocalHint: 'This is required before submission.',
copy: 'Copy',
copied: 'Copied',
submit: 'Submit request',
reset: 'Reset',
addReciprocalFirst: 'Please add this site to your links before submitting.',
submitting: 'Submitting link request...',
submitSuccess: 'Link request submitted. We will review it soon.',
submitFailed: 'Submit failed: {message}',
categoryTech: 'Tech',
categoryLife: 'Life',
categoryDesign: 'Design',
categoryOther: 'Other',
descriptionPlaceholder: 'Briefly describe your site...',
},
friendCard: {
externalLink: 'External link',
},
tags: {
pageTitle: 'Tags',
title: 'Tag Cloud',
intro: 'Browse posts through lightweight keyword slices. When a tag is selected, the result area keeps the same terminal card language.',
currentTag: 'Current: #{tag}',
selectedSummary: 'Tag #{tag} matched {count} posts',
browseTags: 'Browse tags',
emptyTags: 'No tag data yet',
emptyPosts: 'No posts found for this tag',
},
timeline: {
pageTitle: 'Timeline',
pageDescription: 'A timeline of {ownerName}\'s technical growth and life notes',
title: 'Timeline',
subtitle: '{count} entries · tracing {ownerName}\'s technical growth and life notes',
allYears: 'All',
},
reviews: {
pageTitle: 'Reviews',
pageDescription: 'Notes and ratings for games, music, anime, books, and films',
title: 'Reviews',
subtitle: 'Tracking thoughts on games, music, anime, books, and films',
total: 'Total reviews',
average: 'Average rating',
completed: 'Completed',
inProgress: 'In progress',
emptyData: 'No review data yet. Please check the backend API connection.',
emptyFiltered: 'No reviews match the current filter',
currentFilter: 'Current filter: {type}',
typeAll: 'All',
typeGame: 'Games',
typeAnime: 'Anime',
typeMusic: 'Music',
typeBook: 'Books',
typeMovie: 'Films',
},
notFound: {
pageTitle: 'Page not found',
pageDescription: 'The page you requested does not exist',
title: '404 - Page not found',
intro: 'The current request did not resolve to any content node. This page keeps the terminal-style error output, direct recovery actions, and real fallback articles.',
terminalLog: 'Terminal error log',
requestedRouteNotFound: 'error: requested route not found',
path: 'path',
time: 'time',
actions: 'Actions',
actionsIntro: 'Like a command palette, this page surfaces the most direct recovery paths first.',
searchHint: 'You can also use the search box in the header and grep through `articles/*.md` again.',
recommended: 'Recommended entries',
recommendedIntro: 'These use real article data so the 404 page does not send people into more dead ends.',
cannotLoad: 'Unable to load the article list right now.',
},
toc: {
title: 'Contents',
intro: 'Track document headings in real time and jump around like a terminal side panel.',
},
codeCopy: {
copy: 'Copy',
copied: 'Copied',
failed: 'Failed',
},
},
} as const;
export type MessageCatalog = typeof messages;

View File

@@ -61,6 +61,9 @@ export interface SiteSettings {
email?: string;
};
techStack: string[];
ai: {
enabled: boolean;
};
}
export interface SiteConfig {

View File

@@ -92,3 +92,10 @@ export function filterPosts(
export function getPostTypeColor(type: string): string {
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
}
export {
buildParagraphDescriptors,
createParagraphExcerpt,
fnv1aHash,
normalizeParagraphText,
} from './paragraph-comments';

View File

@@ -0,0 +1,57 @@
export interface ParagraphDescriptor {
element: HTMLParagraphElement;
key: string;
excerpt: string;
normalizedText: string;
}
export function normalizeParagraphText(text: string): string {
return text.replace(/\s+/g, ' ').trim().toLowerCase();
}
export function createParagraphExcerpt(text: string, limit = 120): string {
const flattened = text.replace(/\s+/g, ' ').trim();
if (flattened.length <= limit) {
return flattened;
}
return `${flattened.slice(0, limit).trimEnd()}...`;
}
export function fnv1aHash(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
export function buildParagraphDescriptors(container: HTMLElement): ParagraphDescriptor[] {
const occurrences = new Map<string, number>();
const paragraphs = Array.from(container.querySelectorAll('p')).filter(
child => child instanceof HTMLParagraphElement
) as HTMLParagraphElement[];
return paragraphs
.map(element => {
const normalizedText = normalizeParagraphText(element.textContent || '');
if (!normalizedText) {
return null;
}
const hash = fnv1aHash(normalizedText);
const nextOccurrence = (occurrences.get(hash) || 0) + 1;
occurrences.set(hash, nextOccurrence);
return {
element,
key: `p-${hash}-${nextOccurrence}`,
excerpt: createParagraphExcerpt(element.textContent || ''),
normalizedText,
};
})
.filter((item): item is ParagraphDescriptor => Boolean(item));
}

View File

@@ -3,8 +3,10 @@ import Layout from '../layouts/BaseLayout.astro';
import TerminalWindow from '../components/ui/TerminalWindow.astro';
import { terminalConfig } from '../lib/config/terminal';
import { api } from '../lib/api/client';
import { getI18n } from '../lib/i18n';
const fullPrompt = `${terminalConfig.prompt.prefix}${terminalConfig.prompt.separator}${terminalConfig.prompt.path}${terminalConfig.prompt.suffix}`;
const { locale, t } = getI18n(Astro);
let popularPosts: Awaited<ReturnType<typeof api.getPosts>> = [];
@@ -15,7 +17,7 @@ try {
}
---
<Layout title="404 - 页面未找到" description="您访问的页面不存在">
<Layout title={`404 - ${t('notFound.pageTitle')}`} description={t('notFound.pageDescription')}>
<div class="max-w-4xl mx-auto px-4 py-12">
<TerminalWindow title={terminalConfig.title} class="w-full">
<div class="px-4 pb-2">
@@ -33,9 +35,9 @@ EEE RRRR RRRR O O RRRR
E R R R R O O R R
EEEEE R R R R OOO R R</pre>
<div>
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">404 - 页面未找到</h1>
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">404 - {t('notFound.pageTitle')}</h1>
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。
{t('notFound.intro')}
</p>
</div>
</div>
@@ -47,10 +49,10 @@ EEEEE R R R R OOO R R</pre>
<span class="ml-2 text-[var(--secondary)]">find ./ -name "*.html"</span>
</div>
<div class="rounded-2xl border px-4 py-4 text-sm" style="border-color: color-mix(in oklab, var(--danger) 24%, var(--border-color)); background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));">
<div class="text-[var(--text-secondary)]">terminal_error.log</div>
<div class="mt-2 text-[var(--danger)]">error: requested route not found</div>
<div class="mt-2 text-[var(--text-secondary)]">path: <span id="current-path" class="font-mono text-[var(--title-color)]"></span></div>
<div class="text-[var(--text-secondary)]">time: <span id="current-time" class="font-mono text-[var(--title-color)]"></span></div>
<div class="text-[var(--text-secondary)]">{t('notFound.terminalLog')}</div>
<div class="mt-2 text-[var(--danger)]">{t('notFound.requestedRouteNotFound')}</div>
<div class="mt-2 text-[var(--text-secondary)]">{t('notFound.path')}: <span id="current-path" class="font-mono text-[var(--title-color)]"></span></div>
<div class="text-[var(--text-secondary)]">{t('notFound.time')}: <span id="current-time" class="font-mono text-[var(--title-color)]"></span></div>
</div>
</div>
</div>
@@ -62,29 +64,29 @@ EEEEE R R R R OOO R R</pre>
<i class="fas fa-wrench"></i>
</span>
<div>
<h2 class="text-xl font-semibold text-[var(--title-color)]">可执行操作</h2>
<p class="text-sm text-[var(--text-secondary)]">像命令面板一样,优先给出直接可走的恢复路径。</p>
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('notFound.actions')}</h2>
<p class="text-sm text-[var(--text-secondary)]">{t('notFound.actionsIntro')}</p>
</div>
</div>
<div class="flex flex-wrap gap-3">
<button onclick="history.back()" class="terminal-action-button">
<i class="fas fa-arrow-left"></i>
<span>go back</span>
<span>{t('common.goBack')}</span>
</button>
<a href="/" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-house"></i>
<span>home</span>
<span>{t('common.home')}</span>
</a>
<a href="/articles" class="terminal-action-button">
<i class="fas fa-file-lines"></i>
<span>browse posts</span>
<span>{t('common.browsePosts')}</span>
</a>
</div>
<div class="terminal-empty py-8">
<p class="text-sm leading-7 text-[var(--text-secondary)]">
也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。
{t('notFound.searchHint')}
</p>
</div>
</section>
@@ -95,8 +97,8 @@ EEEEE R R R R OOO R R</pre>
<i class="fas fa-book-open"></i>
</span>
<div>
<h2 class="text-xl font-semibold text-[var(--title-color)]">推荐入口</h2>
<p class="text-sm text-[var(--text-secondary)]">使用真实文章数据,避免 404 页面再把人带进不存在的地址。</p>
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('notFound.recommended')}</h2>
<p class="text-sm text-[var(--text-secondary)]">{t('notFound.recommendedIntro')}</p>
</div>
</div>
@@ -113,7 +115,7 @@ EEEEE R R R R OOO R R</pre>
))
) : (
<div class="terminal-empty py-8">
<p class="text-sm text-[var(--text-secondary)]">暂时无法读取文章列表。</p>
<p class="text-sm text-[var(--text-secondary)]">{t('notFound.cannotLoad')}</p>
</div>
)}
</div>
@@ -124,9 +126,9 @@ EEEEE R R R R OOO R R</pre>
</TerminalWindow>
</div>
<script is:inline>
<script is:inline define:vars={{ locale }}>
document.getElementById('current-path').textContent = window.location.pathname;
document.getElementById('current-time').textContent = new Date().toLocaleString('zh-CN', {
document.getElementById('current-time').textContent = new Date().toLocaleString(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',

View File

@@ -6,10 +6,14 @@ import StatsList from '../../components/StatsList.astro';
import TechStackList from '../../components/TechStackList.astro';
import InfoTile from '../../components/ui/InfoTile.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
let systemStats = [];
let techStack = [];
const { t } = getI18n(Astro);
try {
const [settings, posts, tags, friendLinks] = await Promise.all([
@@ -22,26 +26,26 @@ try {
siteSettings = settings;
techStack = siteSettings.techStack.map(name => ({ name }));
systemStats = [
{ label: 'Posts', value: String(posts.length) },
{ label: 'Tags', value: String(tags.length) },
{ label: 'Friends', value: String(friendLinks.filter(friend => friend.status === 'approved').length) },
{ label: 'Location', value: siteSettings.location || 'Unknown' },
{ label: t('common.posts'), value: String(posts.length) },
{ label: t('common.tags'), value: String(tags.length) },
{ label: t('common.friends'), value: String(friendLinks.filter(friend => friend.status === 'approved').length) },
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
];
} catch (error) {
console.error('Failed to load about data:', error);
techStack = siteSettings.techStack.map(name => ({ name }));
systemStats = [
{ label: 'Posts', value: '0' },
{ label: 'Tags', value: '0' },
{ label: 'Friends', value: '0' },
{ label: 'Location', value: siteSettings.location || 'Unknown' },
{ label: t('common.posts'), value: '0' },
{ label: t('common.tags'), value: '0' },
{ label: t('common.friends'), value: '0' },
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
];
}
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
---
<BaseLayout title={`关于 - ${siteSettings.siteShortName}`} description={siteSettings.siteDescription}>
<BaseLayout title={`${t('about.pageTitle')} - ${siteSettings.siteShortName}`} description={siteSettings.siteDescription}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/about" class="w-full">
<div class="mb-6 px-4">
@@ -53,20 +57,20 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
<i class="fas fa-user-circle"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">关于我</h1>
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('about.title')}</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
这里汇总站点主人、技术栈、系统状态和联系方式,现在整体语言会更接近首页与评价页。
{t('about.intro')}
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-location-dot text-[var(--primary)]"></i>
<span>{siteSettings.location || 'Unknown'}</span>
<span>{siteSettings.location || t('common.unknown')}</span>
</span>
<span class="terminal-stat-pill">
<i class="fas fa-layer-group text-[var(--primary)]"></i>
<span>{techStack.length} 项技术栈</span>
<span>{t('about.techStackCount', { count: techStack.length })}</span>
</span>
</div>
</div>
@@ -148,7 +152,7 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
layout="grid"
>
<i class="fas fa-envelope text-[var(--text-secondary)]"></i>
<span class="text-sm">Email</span>
<span class="text-sm">{t('comments.email')}</span>
</InfoTile>
)}
<InfoTile
@@ -159,7 +163,7 @@ const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
rel="noopener noreferrer"
>
<i class="fas fa-globe text-[var(--text-secondary)]"></i>
<span class="text-sm">Website</span>
<span class="text-sm">{t('about.website')}</span>
</InfoTile>
</div>
</div>

View File

@@ -10,7 +10,9 @@ import BackToTop from '../../components/BackToTop.astro';
import Lightbox from '../../components/Lightbox.astro';
import CodeCopyButton from '../../components/CodeCopyButton.astro';
import Comments from '../../components/Comments.astro';
import ParagraphComments from '../../components/ParagraphComments.astro';
import { apiClient } from '../../lib/api/client';
import { formatReadTime, getI18n } from '../../lib/i18n';
import { resolveFileRef, getPostTypeColor } from '../../lib/utils';
export const prerender = false;
@@ -33,6 +35,7 @@ const typeColor = getPostTypeColor(post.type || 'article');
const contentText = post.content || post.description || '';
const wordCount = contentText.length;
const readTimeMinutes = Math.ceil(wordCount / 300);
const { locale, t } = getI18n(Astro);
const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, '');
const markdownProcessor = await createMarkdownProcessor();
@@ -45,7 +48,7 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
<Lightbox />
<CodeCopyButton />
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-article-slug={post.slug}>
<div class="flex flex-col gap-8 lg:flex-row">
<div class="min-w-0 flex-1">
<TerminalWindow title={`~/content/posts/${post.slug}.md`} class="w-full">
@@ -55,17 +58,17 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
<div class="space-y-4">
<a href="/articles" class="terminal-link-arrow">
<i class="fas fa-arrow-left"></i>
<span>返回文章索引</span>
<span>{t('article.backToArticles')}</span>
</a>
<div class="flex flex-wrap items-center gap-2">
<span class="terminal-kicker">
<i class="fas fa-file-code"></i>
document session
{t('article.documentSession')}
</span>
<span class="terminal-chip">
<span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span>
{post.type === 'article' ? 'article' : 'tweet'}
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<span class="terminal-chip">
<i class="fas fa-folder-tree text-[var(--primary)]"></i>
@@ -81,11 +84,11 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
</span>
<span class="terminal-stat-pill">
<i class="far fa-clock text-[var(--primary)]"></i>
{readTimeMinutes} min
{formatReadTime(locale, readTimeMinutes, t)}
</span>
<span class="terminal-stat-pill">
<i class="fas fa-font text-[var(--primary)]"></i>
{wordCount} chars
{t('common.characters', { count: wordCount })}
</span>
</div>
</div>
@@ -126,6 +129,12 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
</div>
</div>
<div class="px-4 pb-2">
<div class="ml-4 mt-4">
<ParagraphComments postSlug={post.slug} />
</div>
</div>
<div class="px-4 py-6">
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span class="text-sm text-[var(--text-secondary)]">
@@ -134,14 +143,14 @@ const renderedContent = await markdownProcessor.render(articleMarkdown);
<div class="flex flex-wrap gap-2">
<a href="/articles" class="terminal-action-button">
<i class="fas fa-list"></i>
<span>back to index</span>
<span>{t('common.backToIndex')}</span>
</a>
<button
class="terminal-action-button terminal-action-button-primary"
onclick={`navigator.clipboard.writeText(window.location.href)`}
>
<i class="fas fa-link"></i>
<span>copy permalink</span>
<span>{t('common.copyPermalink')}</span>
</button>
</div>
</div>

View File

@@ -4,8 +4,8 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import PostCard from '../../components/PostCard.astro';
import { terminalConfig } from '../../lib/config/terminal';
import { api } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Category, Post, Tag } from '../../lib/types';
export const prerender = false;
@@ -15,6 +15,7 @@ let allTags: Tag[] = [];
let allCategories: Category[] = [];
const url = new URL(Astro.request.url);
const selectedSearch = url.searchParams.get('search') || '';
const { t } = getI18n(Astro);
try {
allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts();
@@ -54,9 +55,9 @@ const startIndex = (currentPage - 1) * postsPerPage;
const paginatedPosts = filteredPosts.slice(startIndex, startIndex + postsPerPage);
const postTypeFilters = [
{ id: 'all', name: '全部', icon: 'fa-stream' },
{ id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' },
{ id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' }
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
];
const typePromptCommand = `./filter --type ${selectedType || 'all'}`;
@@ -89,21 +90,21 @@ const buildArticlesUrl = ({
};
---
<BaseLayout title="文章列表 - Termi">
<BaseLayout title={`${t('articlesPage.title')} - Termi`}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/articles/index" class="w-full">
<div class="px-4 pb-2">
<CommandPrompt command="fd . ./content/posts --full-path" />
<div class="ml-4 mt-4 space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">文章索引</h1>
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">{t('articlesPage.title')}</h1>
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
按类型、分类和标签筛选内容。这里保留更轻的 prompt 标题结构,下方筛选拆成独立区域。
{t('articlesPage.description')}
</p>
<div class="flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-file-lines text-[var(--primary)]"></i>
共 {filteredPosts.length} 篇
{t('articlesPage.totalPosts', { count: filteredPosts.length })}
</span>
{selectedSearch && (
<span class="terminal-stat-pill">
@@ -154,7 +155,7 @@ const buildArticlesUrl = ({
active={!selectedCategory}
>
<i class="fas fa-folder-tree"></i>
<span class="font-medium">全部分类</span>
<span class="font-medium">{t('articlesPage.allCategories')}</span>
</FilterPill>
{allCategories.map(category => (
<FilterPill
@@ -181,7 +182,7 @@ const buildArticlesUrl = ({
active={!selectedTag}
>
<i class="fas fa-hashtag"></i>
<span class="font-medium">全部标签</span>
<span class="font-medium">{t('articlesPage.allTags')}</span>
</FilterPill>
{allTags.map(tag => (
<FilterPill
@@ -211,13 +212,13 @@ const buildArticlesUrl = ({
<span class="terminal-section-icon">
<i class="fas fa-folder-open"></i>
</span>
<h2 class="text-xl font-semibold text-[var(--title-color)]">没有匹配结果</h2>
<h2 class="text-xl font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</h2>
<p class="text-sm leading-7 text-[var(--text-secondary)]">
当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。
{t('articlesPage.emptyDescription')}
</p>
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-rotate-left"></i>
<span>reset filters</span>
<span>{t('common.resetFilters')}</span>
</a>
</div>
</div>
@@ -228,7 +229,7 @@ const buildArticlesUrl = ({
<div class="px-4 py-6">
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<span class="text-sm text-[var(--text-secondary)]">
page {currentPage}/{totalPages} · {totalPosts} results
{t('articlesPage.pageSummary', { current: currentPage, total: totalPages, count: totalPosts })}
</span>
<div class="flex flex-wrap gap-2">
{currentPage > 1 && (
@@ -237,7 +238,7 @@ const buildArticlesUrl = ({
class="terminal-action-button"
>
<i class="fas fa-chevron-left"></i>
<span>prev</span>
<span>{t('articlesPage.previous')}</span>
</a>
)}
{currentPage < totalPages && (
@@ -245,7 +246,7 @@ const buildArticlesUrl = ({
href={buildArticlesUrl({ page: currentPage + 1 })}
class="terminal-action-button terminal-action-button-primary"
>
<span>next</span>
<span>{t('articlesPage.next')}</span>
<i class="fas fa-chevron-right"></i>
</a>
)}

View File

@@ -0,0 +1,442 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
const { locale, t } = getI18n(Astro);
try {
siteSettings = await api.getSiteSettings();
} catch (error) {
console.error('Failed to load site settings:', error);
}
const aiEnabled = siteSettings.ai.enabled;
const sampleQuestions = [
locale === 'en'
? 'What kind of topics does this blog mainly cover?'
: '这个博客主要写什么内容?',
locale === 'en'
? 'What recent posts are related to Astro or frontend topics?'
: '最近有哪些和 Astro 或前端相关的文章?',
locale === 'en'
? 'What is the site owner\'s tech stack and personal profile?'
: '站长的技术栈和个人介绍是什么?'
];
---
<BaseLayout title={`${t('ask.pageTitle')} | ${siteSettings.siteShortName}`} description={t('ask.pageDescription', { siteName: siteSettings.siteName })}>
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden">
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
<div>
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">knowledge terminal</div>
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1>
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p>
</div>
<div class:list={[
'rounded-full border px-3 py-1 text-xs font-mono',
aiEnabled
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-300'
]}>
{aiEnabled ? t('common.featureOn') : t('common.featureOff')}
</div>
</div>
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
<div class="min-w-0">
{aiEnabled ? (
<>
<form id="ai-form" class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<label class="mb-3 block text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">user@blog:~/ask$ ./answer</label>
<textarea
id="ai-question"
class="min-h-[140px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
placeholder={t('ask.textareaPlaceholder')}
></textarea>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button type="submit" id="ai-submit" class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/35 bg-[var(--primary)]/10 px-4 py-2 text-sm font-medium text-[var(--primary)] transition hover:bg-[var(--primary)]/16">
<i class="fas fa-terminal text-xs"></i>
<span>{t('ask.submit')}</span>
</button>
<span id="ai-status" class="text-sm text-[var(--text-secondary)]">{t('ask.idleStatus')}</span>
</div>
</form>
<div id="ai-result" class="mt-6 hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/65 p-5">
<div class="flex items-center justify-between gap-3">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">assistant@blog</div>
<div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
</div>
<div id="ai-answer" class="terminal-document mt-4"></div>
<div id="ai-sources" class="mt-5 grid gap-3"></div>
</div>
</>
) : (
<div class="rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/55 px-5 py-8">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">feature disabled</div>
<h2 class="mt-3 text-xl font-semibold text-[var(--title-color)]">{t('ask.disabledTitle')}</h2>
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
{t('ask.disabledDescription')}
</p>
</div>
)}
</div>
<aside class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
<div class="mt-4 space-y-2">
{sampleQuestions.map((question) => (
<button
type="button"
class="sample-question w-full rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-3 py-2 text-left text-sm text-[var(--text-secondary)] transition hover:border-[var(--primary)]/30 hover:text-[var(--text)]"
data-question={question}
>
{question}
</button>
))}
</div>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.workflow')}</div>
<ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]">
<li>{t('ask.workflow1')}</li>
<li>{t('ask.workflow2')}</li>
<li>{t('ask.workflow3')}</li>
</ol>
</div>
</aside>
</div>
</div>
</section>
</BaseLayout>
{aiEnabled && (
<script is:inline define:vars={{ apiBase: 'http://localhost:5150/api' }}>
const t = window.__termiTranslate;
const form = document.getElementById('ai-form');
const input = document.getElementById('ai-question');
const submit = document.getElementById('ai-submit');
const status = document.getElementById('ai-status');
const result = document.getElementById('ai-result');
const answer = document.getElementById('ai-answer');
const sources = document.getElementById('ai-sources');
const meta = document.getElementById('ai-meta');
const sampleButtons = Array.from(document.querySelectorAll('.sample-question'));
const answerCache = new Map();
const prefilledQuestion = new URLSearchParams(window.location.search).get('q')?.trim() || '';
function escapeHtml(value) {
return String(value || '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function renderInlineMarkdown(value) {
let rendered = escapeHtml(value || '');
rendered = rendered.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noreferrer noopener">$1</a>'
);
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
rendered = rendered.replace(/(^|[\s(])\*([^*]+)\*(?=[\s).,!?;:]|$)/g, '$1<em>$2</em>');
return rendered;
}
function renderMarkdown(value) {
const normalized = String(value || '').replace(/\r\n/g, '\n').trim();
if (!normalized) {
return `<p>${escapeHtml(t('ask.emptyAnswer'))}</p>`;
}
const codeBlocks = [];
const withCodePlaceholders = normalized.replace(/```([\w-]*)\n([\s\S]*?)```/g, (_, language, code) => {
const token = `@@CODEBLOCK_${codeBlocks.length}@@`;
codeBlocks.push(
`<pre><code class="language-${escapeHtml(language || 'text')}">${escapeHtml(code.trim())}</code></pre>`
);
return token;
});
const blocks = withCodePlaceholders
.split(/\n{2,}/)
.map((block) => block.trim())
.filter(Boolean);
const html = blocks.map((block) => {
const codeMatch = block.match(/^@@CODEBLOCK_(\d+)@@$/);
if (codeMatch) {
return codeBlocks[Number(codeMatch[1])] || '';
}
const headingMatch = block.match(/^(#{1,4})\s+(.+)$/);
if (headingMatch) {
const level = Math.min(headingMatch[1].length, 4);
return `<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`;
}
const lines = block.split('\n').map((line) => line.trim()).filter(Boolean);
if (lines.length && lines.every((line) => /^[-*]\s+/.test(line))) {
return `<ul>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^[-*]\s+/, ''))}</li>`).join('')}</ul>`;
}
if (lines.length && lines.every((line) => /^\d+\.\s+/.test(line))) {
return `<ol>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^\d+\.\s+/, ''))}</li>`).join('')}</ol>`;
}
if (lines.length && lines.every((line) => /^>\s?/.test(line))) {
return `<blockquote>${lines.map((line) => renderInlineMarkdown(line.replace(/^>\s?/, ''))).join('<br />')}</blockquote>`;
}
return `<p>${lines.map((line) => renderInlineMarkdown(line)).join('<br />')}</p>`;
});
return html.join('');
}
function setInteractiveState(isLoading) {
if (submit) {
submit.toggleAttribute('disabled', isLoading);
}
sampleButtons.forEach((button) => {
button.toggleAttribute('disabled', isLoading);
button.classList.toggle('opacity-60', isLoading);
button.classList.toggle('cursor-not-allowed', isLoading);
});
}
function renderSources(items) {
if (!Array.isArray(items) || !items.length) {
sources.innerHTML = '';
return;
}
sources.innerHTML = `
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">${escapeHtml(t('ask.sources'))}</div>
${items.map((item) => `
<a href="/articles/${encodeURIComponent(item.slug)}" class="block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 transition hover:border-[var(--primary)]/35">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-semibold text-[var(--title-color)]">${escapeHtml(item.title)}</div>
<div class="text-[11px] font-mono text-[var(--text-tertiary)]">score ${escapeHtml(item.score)}</div>
</div>
<div class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">${escapeHtml(item.excerpt)}</div>
</a>
`).join('')}
`;
}
async function readErrorMessage(response) {
const rawText = await response.text().catch(() => '');
if (!rawText) {
return t('ask.requestFailed', { message: response.status });
}
try {
const parsed = JSON.parse(rawText);
return parsed.description || parsed.error || rawText;
} catch {
return rawText;
}
}
function renderResult(data) {
result.classList.remove('hidden');
answer.innerHTML = renderMarkdown(data.answer);
meta.textContent = `chunks ${data.indexed_chunks}${data.last_indexed_at ? ` · indexed ${data.last_indexed_at}` : ''}`;
renderSources(data.sources || []);
}
function takeNextSseEvent(buffer) {
const lfBoundary = buffer.indexOf('\n\n');
const crlfBoundary = buffer.indexOf('\r\n\r\n');
let boundaryIndex = -1;
let separatorLength = 0;
if (lfBoundary !== -1) {
boundaryIndex = lfBoundary;
separatorLength = 2;
}
if (crlfBoundary !== -1 && (boundaryIndex === -1 || crlfBoundary < boundaryIndex)) {
boundaryIndex = crlfBoundary;
separatorLength = 4;
}
if (boundaryIndex === -1) {
return null;
}
const rawEvent = buffer.slice(0, boundaryIndex);
const rest = buffer.slice(boundaryIndex + separatorLength);
const normalized = rawEvent.replace(/\r\n/g, '\n');
const lines = normalized.split('\n');
let event = 'message';
const dataLines = [];
for (const line of lines) {
if (line.startsWith('event:')) {
event = line.slice(6).trim() || 'message';
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).trimStart());
}
}
return {
event,
data: dataLines.join('\n'),
rest
};
}
async function readStreamResponse(response, handlers) {
const reader = response.body?.getReader();
if (!reader) {
throw new Error(t('ask.streamUnsupported'));
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
while (true) {
const parsed = takeNextSseEvent(buffer);
if (!parsed) {
break;
}
buffer = parsed.rest;
if (!parsed.data || parsed.data === '[DONE]') {
continue;
}
let payload = null;
try {
payload = JSON.parse(parsed.data);
} catch {
continue;
}
handlers[parsed.event]?.(payload);
}
if (done) {
break;
}
}
}
async function ask(question) {
const trimmed = String(question || '').trim();
if (!trimmed) {
status.textContent = t('ask.enterQuestion');
input?.focus();
return;
}
const cached = answerCache.get(trimmed);
if (cached) {
renderResult(cached);
status.textContent = t('ask.cacheRestored');
return;
}
setInteractiveState(true);
result.classList.remove('hidden');
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`;
sources.innerHTML = '';
meta.textContent = '';
try {
const response = await fetch(`${apiBase}/ai/ask/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ question: trimmed })
});
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
let streamedAnswer = '';
let completed = false;
await readStreamResponse(response, {
status: () => {
status.textContent = t('ask.processing');
},
delta: (payload) => {
streamedAnswer += payload.delta || '';
answer.innerHTML = renderMarkdown(streamedAnswer);
},
complete: (payload) => {
completed = true;
answerCache.set(trimmed, payload);
renderResult(payload);
status.textContent = t('ask.complete');
},
error: (payload) => {
throw new Error(payload.message || t('ask.streamFailed'));
}
});
if (!completed) {
throw new Error(t('ask.streamInterrupted'));
}
} catch (error) {
result.classList.remove('hidden');
answer.innerHTML = `<p>${escapeHtml(t('ask.requestFailed', { message: error?.message || 'unknown error' }))}</p>`;
meta.textContent = '';
sources.innerHTML = '';
status.textContent = t('ask.retryLater');
} finally {
setInteractiveState(false);
}
}
form?.addEventListener('submit', (event) => {
event.preventDefault();
ask(input?.value || '');
});
document.querySelectorAll('.sample-question').forEach((button) => {
button.addEventListener('click', () => {
const question = button.getAttribute('data-question') || '';
if (input) {
input.value = question;
}
ask(question);
});
});
if (prefilledQuestion) {
if (input) {
input.value = prefilledQuestion;
input.focus();
}
status.textContent = t('ask.prefixedQuestion');
}
</script>
)}

View File

@@ -3,8 +3,12 @@ import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import { api } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
export const prerender = false;
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
const { t } = getI18n(Astro);
try {
categories = await api.getCategories();
@@ -13,7 +17,7 @@ try {
}
---
<BaseLayout title="分类 - Termi">
<BaseLayout title={`${t('categories.pageTitle')} - Termi`}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/categories" class="w-full">
<div class="mb-6 px-4">
@@ -25,20 +29,20 @@ try {
<i class="fas fa-folder-open"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">文章分类</h1>
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('categories.title')}</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。
{t('categories.intro')}
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-layer-group text-[var(--primary)]"></i>
<span>{categories.length} 个分类</span>
<span>{t('common.categoriesCount', { count: categories.length })}</span>
</span>
<span class="terminal-stat-pill">
<i class="fas fa-terminal text-[var(--primary)]"></i>
<span>快速跳转分类文章</span>
<span>{t('categories.quickJump')}</span>
</span>
</div>
</div>
@@ -67,14 +71,14 @@ try {
</h3>
</div>
<span class="terminal-chip text-xs py-1 px-2.5">
<span>{category.count} 篇</span>
<span>{t('common.postsCount', { count: category.count })}</span>
</span>
</div>
<p class="text-sm leading-6 text-[var(--text-secondary)]">
浏览 {category.name} 主题下的全部文章和更新记录。
{t('categories.categoryPosts', { name: category.name })}
</p>
<div class="mt-4 terminal-link-arrow">
<span>查看分类文章</span>
<span>{t('common.viewCategoryArticles')}</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</div>
@@ -85,7 +89,7 @@ try {
) : (
<div class="terminal-empty">
<i class="fas fa-inbox text-4xl mb-4 opacity-50 text-[var(--primary)]"></i>
<p class="text-[var(--text-secondary)]">暂无分类数据</p>
<p class="text-[var(--text-secondary)]">{t('categories.empty')}</p>
</div>
)}
</div>

View File

@@ -5,11 +5,15 @@ import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FriendLinkCard from '../../components/FriendLinkCard.astro';
import FriendLinkApplication from '../../components/FriendLinkApplication.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { AppFriendLink } from '../../lib/api/client';
export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
let friendLinks: AppFriendLink[] = [];
let error: string | null = null;
const { t } = getI18n(Astro);
try {
[siteSettings, friendLinks] = await Promise.all([
@@ -18,18 +22,18 @@ try {
]);
friendLinks = friendLinks.filter(friend => friend.status === 'approved');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch friend links';
error = e instanceof Error ? e.message : t('common.apiUnavailable');
console.error('Failed to fetch friend links:', e);
}
const categories = [...new Set(friendLinks.map(friend => friend.category || '其他'))];
const categories = [...new Set(friendLinks.map(friend => friend.category || t('common.other')))];
const groupedLinks = categories.map(category => ({
category,
links: friendLinks.filter(friend => (friend.category || '其他') === category)
links: friendLinks.filter(friend => (friend.category || t('common.other')) === category)
}));
---
<BaseLayout title={`友情链接 - ${siteSettings.siteShortName}`} description={`与 ${siteSettings.siteName} 交换友情链接`}>
<BaseLayout title={`${t('friends.pageTitle')} - ${siteSettings.siteShortName}`} description={t('friends.pageDescription', { siteName: siteSettings.siteName })}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/friends" class="w-full">
<div class="mb-6 px-4">
@@ -41,20 +45,20 @@ const groupedLinks = categories.map(category => ({
<i class="fas fa-link"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">友情链接</h1>
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('friends.title')}</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。
{t('friends.intro')}
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-globe text-[var(--primary)]"></i>
<span>{friendLinks.length} 个友链</span>
<span>{t('common.friendsCount', { count: friendLinks.length })}</span>
</span>
<span class="terminal-stat-pill">
<i class="fas fa-check-circle text-[var(--primary)]"></i>
<span>仅展示已通过审核</span>
<span>{t('common.reviewedOnly')}</span>
</span>
</div>
</div>
@@ -78,7 +82,7 @@ const groupedLinks = categories.map(category => ({
</span>
<div>
<h2 class="text-lg font-bold text-[var(--title-color)]">{group.category}</h2>
<p class="text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">friend collection</p>
<p class="text-xs uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{t('friends.collection')}</p>
</div>
</div>
<span class="terminal-chip text-xs py-1 px-2.5">({group.links.length})</span>
@@ -103,21 +107,21 @@ const groupedLinks = categories.map(category => ({
<CommandPrompt command="cat ./exchange_info.txt" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">exchange rules</div>
<h3 class="mt-4 font-bold text-[var(--title-color)] text-lg">友链交换</h3>
<h3 class="mt-4 font-bold text-[var(--title-color)] text-lg">{t('friends.exchangeRules')}</h3>
<p class="mt-3 text-sm text-[var(--text-secondary)] mb-4 leading-6">
欢迎交换友情链接!请确保您的网站满足以下条件:
{t('friends.exchangeIntro')}
</p>
<ul class="text-sm text-[var(--text-secondary)] space-y-1 list-disc list-inside">
<li>原创内容为主</li>
<li>网站稳定运行</li>
<li>无不良内容</li>
<li>{t('friends.rule1')}</li>
<li>{t('friends.rule2')}</li>
<li>{t('friends.rule3')}</li>
</ul>
<div class="mt-5 pt-4 border-t border-[var(--border-color)]">
<p class="text-sm text-[var(--text-tertiary)] font-mono">
本站信息:<br/>
名称: {siteSettings.siteName}<br/>
描述: {siteSettings.siteDescription}<br/>
链接: {siteSettings.siteUrl}
{t('friends.siteInfo')}<br/>
{t('friends.name')}: {siteSettings.siteName}<br/>
{t('friends.description')}: {siteSettings.siteDescription}<br/>
{t('friends.link')}: {siteSettings.siteUrl}
</p>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import StatsList from '../components/StatsList.astro';
import TechStackList from '../components/TechStackList.astro';
import { terminalConfig } from '../lib/config/terminal';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { formatReadTime, getI18n } from '../lib/i18n';
import type { AppFriendLink } from '../lib/api/client';
import type { Post } from '../lib/types';
@@ -26,6 +27,7 @@ let tags: string[] = [];
let friendLinks: AppFriendLink[] = [];
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
let apiError: string | null = null;
const { locale, t } = getI18n(Astro);
try {
siteSettings = await api.getSiteSettings();
@@ -40,23 +42,32 @@ try {
friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved');
categories = await api.getCategories();
} catch (error) {
apiError = error instanceof Error ? error.message : 'API unavailable';
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
console.error('API Error:', error);
}
const systemStats = [
{ label: '文章', value: String(allPosts.length) },
{ label: '标签', value: String(tags.length) },
{ label: '分类', value: String(categories.length) },
{ label: '友链', value: String(friendLinks.length) },
{ label: t('common.posts'), value: String(allPosts.length) },
{ label: t('common.tags'), value: String(tags.length) },
{ label: t('common.categories'), value: String(categories.length) },
{ label: t('common.friends'), value: String(friendLinks.length) },
];
const techStack = siteSettings.techStack.map(name => ({ name }));
const postTypeFilters = [
{ id: 'all', name: '全部', icon: 'fa-stream' },
{ id: 'article', name: terminalConfig.postTypes.article.label, icon: 'fa-file-alt' },
{ id: 'tweet', name: terminalConfig.postTypes.tweet.label, icon: 'fa-comment-dots' }
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
];
const navLinks = [
{ 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' },
];
---
@@ -78,7 +89,7 @@ const postTypeFilters = [
<div class="mb-8 px-4">
<CommandPrompt command="ls -l" />
<div class="ml-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-4">
{terminalConfig.navLinks.map(link => (
{navLinks.map(link => (
<a
href={link.href}
class="flex items-center gap-2 text-[var(--text)] hover:text-[var(--primary)] transition-colors py-2"
@@ -126,17 +137,22 @@ const postTypeFilters = [
<div class="mb-8 px-4">
<CommandPrompt command="cat ./pinned_post.md" />
<div class="ml-4">
<div
class="p-4 rounded-lg border border-[var(--border-color)] bg-[var(--header-bg)] cursor-pointer hover:border-[var(--primary)] transition-colors"
onclick={`window.location.href='/articles/${pinnedPost.slug}'`}
>
<div class="p-4 rounded-lg border border-[var(--border-color)] bg-[var(--header-bg)] transition-colors hover:border-[var(--primary)]">
<div class="flex items-center gap-2 mb-2">
<span class="px-2 py-0.5 text-xs rounded bg-[var(--primary)] text-[var(--terminal-bg)] font-bold">置顶</span>
<span class="px-2 py-0.5 text-xs rounded bg-[var(--primary)] text-[var(--terminal-bg)] font-bold">{t('home.pinned')}</span>
<span class="w-3 h-3 rounded-full" style={`background-color: ${pinnedPost.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}></span>
<h3 class="text-lg font-bold">{pinnedPost.title}</h3>
<a href={`/articles/${pinnedPost.slug}`} class="text-lg font-bold text-[var(--title-color)] transition hover:text-[var(--primary)]">
{pinnedPost.title}
</a>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-2">{pinnedPost.date} | 阅读时间: {pinnedPost.readTime}</p>
<p class="text-sm text-[var(--text-secondary)] mb-2">{pinnedPost.date} | {t('common.readTime')}: {formatReadTime(locale, pinnedPost.readTime, t)}</p>
<p class="text-[var(--text-secondary)]">{pinnedPost.description}</p>
<div class="mt-4">
<a href={`/articles/${pinnedPost.slug}`} class="terminal-action-button inline-flex">
<i class="fas fa-angle-right"></i>
<span>{t('common.readMore')}</span>
</a>
</div>
</div>
</div>
</div>
@@ -151,7 +167,7 @@ const postTypeFilters = [
))}
</div>
<div class="mt-4">
<ViewMoreLink href="/articles" text="查看所有文章" />
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} />
</div>
</div>
</div>
@@ -178,7 +194,7 @@ const postTypeFilters = [
))}
</div>
<div class="mt-6 ml-4">
<ViewMoreLink href="/friends" text="查看全部友链" />
<ViewMoreLink href="/friends" text={t('common.viewAllLinks')} />
</div>
</div>
@@ -189,15 +205,15 @@ const postTypeFilters = [
<div class="ml-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">关于我</h3>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.about')}</h3>
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">技术栈</h3>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.techStack')}</h3>
<TechStackList items={techStack} />
</div>
<div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">系统状态</h3>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.systemStatus')}</h3>
<StatsList stats={systemStats} />
</div>
</div>

View File

@@ -5,8 +5,11 @@ import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import InfoTile from '../../components/ui/InfoTile.astro';
import { apiClient } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n';
import type { Review } from '../../lib/api/client';
export const prerender = false;
type ParsedReview = Omit<Review, 'tags'> & {
tags: string[];
};
@@ -15,6 +18,7 @@ type ParsedReview = Omit<Review, 'tags'> & {
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
const url = new URL(Astro.request.url);
const selectedType = url.searchParams.get('type') || 'all';
const { t } = getI18n(Astro);
try {
reviews = await apiClient.getReviews();
} catch (error) {
@@ -41,20 +45,20 @@ const stats = {
};
const filters = [
{ id: 'all', name: '全部', icon: 'fa-list', count: parsedReviews.length },
{ id: 'game', name: '游戏', icon: 'fa-gamepad', count: parsedReviews.filter(r => r.review_type === 'game').length },
{ id: 'anime', name: '动画', icon: 'fa-tv', count: parsedReviews.filter(r => r.review_type === 'anime').length },
{ id: 'music', name: '音乐', icon: 'fa-music', count: parsedReviews.filter(r => r.review_type === 'music').length },
{ id: 'book', name: '书籍', icon: 'fa-book', count: parsedReviews.filter(r => r.review_type === 'book').length },
{ id: 'movie', name: '影视', icon: 'fa-film', count: parsedReviews.filter(r => r.review_type === 'movie').length }
{ id: 'all', name: t('reviews.typeAll'), icon: 'fa-list', count: parsedReviews.length },
{ id: 'game', name: t('reviews.typeGame'), icon: 'fa-gamepad', count: parsedReviews.filter(r => r.review_type === 'game').length },
{ id: 'anime', name: t('reviews.typeAnime'), icon: 'fa-tv', count: parsedReviews.filter(r => r.review_type === 'anime').length },
{ id: 'music', name: t('reviews.typeMusic'), icon: 'fa-music', count: parsedReviews.filter(r => r.review_type === 'music').length },
{ id: 'book', name: t('reviews.typeBook'), icon: 'fa-book', count: parsedReviews.filter(r => r.review_type === 'book').length },
{ id: 'movie', name: t('reviews.typeMovie'), icon: 'fa-film', count: parsedReviews.filter(r => r.review_type === 'movie').length }
];
const typeLabels: Record<string, string> = {
game: '游戏',
anime: '动画',
music: '音乐',
book: '书籍',
movie: '影视'
game: t('reviews.typeGame'),
anime: t('reviews.typeAnime'),
music: t('reviews.typeMusic'),
book: t('reviews.typeBook'),
movie: t('reviews.typeMovie')
};
const typeColors: Record<string, string> = {
@@ -66,7 +70,7 @@ const typeColors: Record<string, string> = {
};
---
<Layout title="评价 | Termi" description="记录游戏、音乐、动画、书籍的体验与评价">
<Layout title={`${t('reviews.pageTitle')} | Termi`} description={t('reviews.pageDescription')}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Single Terminal Window for entire page -->
<TerminalWindow title="~/reviews" class="w-full">
@@ -79,13 +83,13 @@ const typeColors: Record<string, string> = {
<div class="terminal-kicker">review ledger</div>
<div class="terminal-section-title mt-4">
<span class="terminal-section-icon">
<i class="fas fa-star"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">评价</h1>
<i class="fas fa-star"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('reviews.title')}</h1>
<p id="reviews-subtitle" class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
记录游戏、音乐、动画、书籍的体验与感悟
{selectedType !== 'all' && ` · 当前筛选: ${typeLabels[selectedType] || selectedType}`}
{t('reviews.subtitle')}
{selectedType !== 'all' && ` · ${t('reviews.currentFilter', { type: typeLabels[selectedType] || selectedType })}`}
</p>
</div>
</div>
@@ -97,19 +101,19 @@ const typeColors: Record<string, string> = {
<div class="ml-4 mt-2 grid grid-cols-2 sm:grid-cols-4 gap-4">
<InfoTile tone="blue" layout="stack">
<div id="reviews-total" class="text-2xl font-bold text-[var(--primary)]">{stats.total}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">总评价</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.total')}</div>
</InfoTile>
<InfoTile tone="amber" layout="stack">
<div id="reviews-average" class="text-2xl font-bold text-yellow-500">{stats.avgRating}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">平均评分</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.average')}</div>
</InfoTile>
<InfoTile tone="teal" layout="stack">
<div id="reviews-completed" class="text-2xl font-bold text-[var(--success)]">{stats.completed}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">已完成</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.completed')}</div>
</InfoTile>
<InfoTile tone="violet" layout="stack">
<div id="reviews-progress" class="text-2xl font-bold text-[var(--warning)]">{stats.inProgress}</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">进行中</div>
<div class="mt-2 text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('reviews.inProgress')}</div>
</InfoTile>
</div>
</div>
@@ -146,7 +150,7 @@ const typeColors: Record<string, string> = {
<i class="fas fa-inbox"></i>
</div>
<div class="text-[var(--text-secondary)]">
{parsedReviews.length === 0 ? '暂无评价数据,请检查后端 API 连接' : '当前筛选下暂无评价'}
{parsedReviews.length === 0 ? t('reviews.emptyData') : t('reviews.emptyFiltered')}
</div>
</div>
) : (
@@ -201,7 +205,7 @@ const typeColors: Record<string, string> = {
<div class="text-3xl text-[var(--primary)] mb-3">
<i class="fas fa-inbox"></i>
</div>
<div class="text-[var(--text-secondary)]">当前筛选下暂无评价</div>
<div class="text-[var(--text-secondary)]">{t('reviews.emptyFiltered')}</div>
</div>
</>
)}
@@ -218,16 +222,22 @@ const typeColors: Record<string, string> = {
</div>
</TerminalWindow>
</div>
<script is:inline>
<script
is:inline
define:vars={{
reviewTypeLabels: {
game: t('reviews.typeGame'),
anime: t('reviews.typeAnime'),
music: t('reviews.typeMusic'),
book: t('reviews.typeBook'),
movie: t('reviews.typeMovie'),
all: t('reviews.typeAll'),
},
reviewsBaseSubtitle: t('reviews.subtitle'),
}}
>
(function() {
const typeLabels = {
game: '游戏',
anime: '动画',
music: '音乐',
book: '书籍',
movie: '影视',
all: '全部'
};
const typeLabels = reviewTypeLabels;
const cards = Array.from(document.querySelectorAll('[data-review-card]'));
const filters = Array.from(document.querySelectorAll('[data-review-filter]'));
@@ -237,7 +247,8 @@ const typeColors: Record<string, string> = {
const completedEl = document.getElementById('reviews-completed');
const progressEl = document.getElementById('reviews-progress');
const emptyState = document.getElementById('reviews-empty-state');
const baseSubtitle = '记录游戏、音乐、动画、书籍的体验与感悟';
const t = window.__termiTranslate;
const baseSubtitle = reviewsBaseSubtitle;
function updateFilterUi(activeType) {
filters.forEach((filter) => {
@@ -277,7 +288,7 @@ const typeColors: Record<string, string> = {
if (subtitle) {
subtitle.textContent = type === 'all'
? baseSubtitle
: `${baseSubtitle} · 当前筛选: ${typeLabels[type] || type}`;
: `${baseSubtitle} · ${t('reviews.currentFilter', { type: typeLabels[type] || type })}`;
}
if (pushState) {

View File

@@ -4,6 +4,7 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import { apiClient } from '../../lib/api/client';
import { getI18n, formatReadTime } from '../../lib/i18n';
import type { Post, Tag } from '../../lib/types';
export const prerender = false;
@@ -11,6 +12,7 @@ export const prerender = false;
// Fetch tags from backend
let tags: Tag[] = [];
let filteredPosts: Post[] = [];
const { locale, t } = getI18n(Astro);
try {
tags = await apiClient.getTags();
@@ -35,7 +37,7 @@ if (selectedTag) {
}
---
<BaseLayout title="标签 - Termi">
<BaseLayout title={`${t('tags.pageTitle')} - Termi`}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/tags" class="w-full">
<div class="mb-6 px-4">
@@ -47,21 +49,21 @@ if (selectedTag) {
<i class="fas fa-hashtag"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">标签云</h1>
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('tags.title')}</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。
{t('tags.intro')}
</p>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-tags text-[var(--primary)]"></i>
<span>{tags.length} 个标签</span>
<span>{t('common.tagsCount', { count: tags.length })}</span>
</span>
{selectedTag && (
<span class="terminal-stat-pill">
<i class="fas fa-filter text-[var(--primary)]"></i>
<span>当前: #{selectedTag}</span>
<span>{t('tags.currentTag', { tag: selectedTag })}</span>
</span>
)}
</div>
@@ -74,12 +76,11 @@ if (selectedTag) {
<div class="terminal-panel ml-4 mt-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p class="text-[var(--text-secondary)] leading-6">
标签 <span class="text-[var(--primary)] font-bold">#{selectedTag}</span>
找到 {filteredPosts.length} 篇文章
{t('tags.selectedSummary', { tag: selectedTag, count: filteredPosts.length })}
</p>
<FilterPill tone="teal" href="/tags">
<i class="fas fa-times"></i>
<span>清除筛选</span>
<span>{t('common.clearFilters')}</span>
</FilterPill>
</div>
</div>
@@ -89,12 +90,12 @@ if (selectedTag) {
<div class="px-4 mb-8">
<div class="terminal-panel ml-4 mt-4">
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-4">
browse tags
{t('tags.browseTags')}
</div>
<div class="flex flex-wrap gap-2">
{tags.length === 0 ? (
<div class="terminal-empty w-full">
暂无标签数据
{t('tags.emptyTags')}
</div>
) : (
tags.map(tag => (
@@ -129,10 +130,10 @@ if (selectedTag) {
<span>{post.category}</span>
</span>
</div>
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {post.readTime}</p>
<p class="text-sm text-[var(--text-secondary)]">{post.date} | {formatReadTime(locale, post.readTime, t)}</p>
<p class="text-sm text-[var(--text-secondary)] mt-3 leading-6">{post.description}</p>
<div class="mt-4 terminal-link-arrow">
<span>打开文章</span>
<span>{t('common.viewArticle')}</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</a>
@@ -147,7 +148,7 @@ if (selectedTag) {
<div class="border-t border-[var(--border-color)] pt-6">
<div class="terminal-empty ml-4 mt-4">
<i class="fas fa-search text-4xl text-[var(--text-tertiary)] mb-4"></i>
<p class="text-[var(--text-secondary)]">没有找到该标签的文章</p>
<p class="text-[var(--text-secondary)]">{t('tags.emptyPosts')}</p>
</div>
</div>
</div>

View File

@@ -4,10 +4,14 @@ import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import FilterPill from '../../components/ui/FilterPill.astro';
import { api, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
import { getI18n, formatReadTime } from '../../lib/i18n';
import type { Post } from '../../lib/types';
export const prerender = false;
let siteSettings = DEFAULT_SITE_SETTINGS;
let posts: Post[] = [];
const { locale, t } = getI18n(Astro);
try {
[siteSettings, posts] = await Promise.all([
@@ -29,7 +33,7 @@ const years = Object.keys(groupedByYear).sort((a, b) => Number(b) - Number(a));
const latestYear = years[0] || 'all';
---
<Layout title={`时间轴 | ${siteSettings.siteShortName}`} description={`记录 ${siteSettings.ownerName} 的技术成长与生活点滴`}>
<Layout title={`${t('timeline.pageTitle')} | ${siteSettings.siteShortName}`} description={t('timeline.pageDescription', { ownerName: siteSettings.ownerName })}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/timeline" class="w-full">
<div class="px-4 py-4 space-y-6">
@@ -39,12 +43,12 @@ const latestYear = years[0] || 'all';
<div class="terminal-kicker">activity trace</div>
<div class="terminal-section-title mt-4">
<span class="terminal-section-icon">
<i class="fas fa-stream"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">时间轴</h1>
<i class="fas fa-stream"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">{t('timeline.title')}</h1>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
共 {posts.length} 篇内容 · 记录 {siteSettings.ownerName} 的技术成长与生活点滴
{t('timeline.subtitle', { count: posts.length, ownerName: siteSettings.ownerName })}
</p>
</div>
</div>
@@ -60,7 +64,7 @@ const latestYear = years[0] || 'all';
data-year="all"
active={false}
>
全部
{t('timeline.allYears')}
</FilterPill>
{years.map(year => (
<FilterPill
@@ -102,7 +106,7 @@ const latestYear = years[0] || 'all';
>
<div class="terminal-panel-muted shrink-0 min-w-[72px] text-center py-3">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{new Date(post.date).toLocaleDateString('zh-CN', { month: 'short' })}
{new Date(post.date).toLocaleDateString(locale, { month: 'short' })}
</div>
<div class="mt-1 text-2xl font-bold text-[var(--primary)]">
{new Date(post.date).getDate()}
@@ -125,7 +129,7 @@ const latestYear = years[0] || 'all';
{post.category}
</span>
<span class="terminal-chip text-xs py-1 px-2.5">
{post.readTime}
{formatReadTime(locale, post.readTime, t)}
</span>
</div>
</div>

View File

@@ -674,6 +674,167 @@ html.dark {
.ui-info-tile--neutral {
--tile-rgb: 100 116 139;
}
.paragraph-comments-intro {
@apply flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between;
}
.paragraph-comments-shell {
@apply space-y-4;
}
.paragraph-comments-summary {
@apply shrink-0 self-start;
}
.paragraph-comment-paragraph {
position: relative;
scroll-margin-top: 8rem;
transition:
color 0.2s ease,
background-color 0.2s ease,
box-shadow 0.2s ease;
}
.paragraph-comment-paragraph.is-comment-focused {
border-radius: 0.85rem;
background: color-mix(in oklab, var(--primary) 7%, transparent);
box-shadow:
0 0 0 1px color-mix(in oklab, var(--primary) 14%, var(--border-color)),
0 12px 26px rgba(var(--text-rgb), 0.04);
padding: 0.35rem 0.6rem;
margin-left: -0.6rem;
margin-right: -0.6rem;
}
.paragraph-comment-row {
@apply mt-3 flex flex-col gap-2 rounded-lg border px-3 py-2 sm:flex-row sm:items-center sm:justify-between;
border-color: color-mix(in oklab, var(--primary) 9%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));
}
.paragraph-comment-row:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
}
.paragraph-comment-row.is-active {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
box-shadow:
inset 0 0 0 1px rgba(var(--primary-rgb), 0.08),
0 10px 24px rgba(var(--text-rgb), 0.04);
}
.paragraph-comment-command {
@apply min-w-0 font-mono text-[11px] leading-6 sm:text-[12px];
color: var(--text-secondary);
}
.paragraph-comment-prompt {
color: var(--primary);
}
.paragraph-comment-command-text {
color: var(--title-color);
word-break: break-all;
}
.paragraph-comment-actions {
@apply flex flex-wrap items-center gap-2;
}
.paragraph-comment-hint {
@apply inline-flex items-center rounded-md border px-2 py-1 text-[10px] font-mono uppercase tracking-[0.18em];
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
color: var(--text-tertiary);
background: color-mix(in oklab, var(--header-bg) 84%, transparent);
}
.paragraph-comment-panel {
@apply mt-4 space-y-5;
}
.paragraph-comment-panel-head {
@apply flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between;
}
.paragraph-comment-panel-meta {
@apply flex flex-wrap items-center gap-2;
}
.paragraph-comment-thread {
@apply space-y-3;
}
.paragraph-thread-segment {
@apply space-y-3;
}
.paragraph-thread-segment-label {
@apply inline-flex items-center rounded-md border px-2 py-1 text-[10px] font-mono uppercase tracking-[0.18em];
border-color: color-mix(in oklab, var(--primary) 9%, var(--border-color));
background: color-mix(in oklab, var(--header-bg) 82%, transparent);
color: var(--text-tertiary);
}
.paragraph-thread-item {
@apply rounded-xl border p-4;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));
}
.paragraph-thread-item.is-reply {
border-color: color-mix(in oklab, var(--secondary) 18%, var(--border-color));
}
.paragraph-thread-item.is-pending {
border-style: dashed;
border-color: color-mix(in oklab, var(--warning) 22%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--warning) 4%, var(--terminal-bg)), color-mix(in oklab, var(--header-bg) 88%, transparent));
}
.paragraph-thread-head {
@apply mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between;
}
.paragraph-thread-quote {
@apply rounded-lg border-l-2 px-3 py-2 text-xs leading-6;
border-left-color: color-mix(in oklab, var(--primary) 34%, var(--border-color));
background: color-mix(in oklab, var(--primary) 6%, var(--header-bg));
color: var(--text-tertiary);
}
.paragraph-comment-form {
@apply space-y-4;
}
.paragraph-comment-reply {
@apply flex items-center justify-between gap-3 py-3;
}
.paragraph-comment-status {
@apply rounded-xl border px-4 py-3 text-sm;
}
.paragraph-comment-status-success {
border-color: color-mix(in oklab, var(--success) 28%, var(--border-color));
background: color-mix(in oklab, var(--success) 10%, var(--header-bg));
color: var(--success);
}
.paragraph-comment-status-error {
border-color: color-mix(in oklab, var(--danger) 28%, var(--border-color));
background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));
color: var(--danger);
}
.paragraph-comment-status-info {
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
background: color-mix(in oklab, var(--primary) 10%, var(--header-bg));
color: var(--primary);
}
}
/* Animations */