chore: reorganize project into monorepo

This commit is contained in:
2026-03-28 10:40:22 +08:00
parent 60367a5f51
commit 1455d93246
201 changed files with 30081 additions and 93 deletions

View File

@@ -0,0 +1,46 @@
---
// Back to Top Button Component
---
<button
id="back-to-top"
class="fixed bottom-8 right-8 w-12 h-12 rounded-full bg-[var(--header-bg)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--primary)] hover:border-[var(--primary)] transition-all opacity-0 translate-y-4 z-50 flex items-center justify-center shadow-lg"
aria-label="Back to top"
>
<i class="fas fa-chevron-up"></i>
</button>
<script is:inline>
(function() {
const backToTopBtn = document.getElementById('back-to-top');
if (!backToTopBtn) return;
function toggleVisibility() {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
if (scrollTop > 300) {
backToTopBtn.classList.remove('opacity-0', 'translate-y-4');
backToTopBtn.classList.add('opacity-100', 'translate-y-0');
} else {
backToTopBtn.classList.add('opacity-0', 'translate-y-4');
backToTopBtn.classList.remove('opacity-100', 'translate-y-0');
}
}
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
// Show/hide on scroll
window.addEventListener('scroll', toggleVisibility, { passive: true });
// Click to scroll to top
backToTopBtn.addEventListener('click', scrollToTop);
// Initial check
toggleVisibility();
})();
</script>

View File

@@ -0,0 +1,41 @@
---
interface Props {
code: string;
language?: string;
class?: string;
}
const { code, language = 'bash', class: className = '' } = Astro.props;
// Simple syntax highlighting classes
const languageColors: Record<string, string> = {
bash: 'text-[var(--primary)]',
javascript: 'text-[#f7df1e]',
typescript: 'text-[#3178c6]',
python: 'text-[#3776ab]',
rust: 'text-[#dea584]',
go: 'text-[#00add8]',
html: 'text-[#e34c26]',
css: 'text-[#264de4]',
json: 'text-[var(--secondary)]',
};
const langColor = languageColors[language] || 'text-[var(--text)]';
---
<div class={`rounded-lg overflow-hidden bg-[var(--code-bg)] border border-[var(--border-color)] ${className}`}>
<!-- Code header -->
<div class="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)] bg-[var(--header-bg)]">
<span class="text-xs text-[var(--text-secondary)] font-mono uppercase">{language}</span>
<button
class="text-xs text-[var(--text-tertiary)] hover:text-[var(--primary)] transition-colors"
onclick={`navigator.clipboard.writeText(\`${code.replace(/\\/g, '\\\\').replace(/`/g, '\\`')}\`)`}
>
<i class="fas fa-copy mr-1"></i>
Copy
</button>
</div>
<!-- Code content -->
<pre class="p-3 overflow-x-auto"><code class={`font-mono text-sm ${langColor} whitespace-pre`}>{code}</code></pre>
</div>

View File

@@ -0,0 +1,61 @@
---
// Code Block Copy Button Component
// Adds copy functionality to all code blocks
---
<script is:inline>
(function() {
function initCodeCopy() {
const codeBlocks = document.querySelectorAll('pre code');
codeBlocks.forEach(code => {
const pre = code.parentElement;
if (!pre || pre.classList.contains('code-copy-enabled')) return;
pre.classList.add('code-copy-enabled', 'relative', 'group');
// 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.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(code.textContent || '');
button.innerHTML = '<i class="fas fa-check mr-1"></i>已复制';
button.classList.add('text-[var(--success)]');
setTimeout(() => {
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
button.classList.remove('text-[var(--success)]');
}, 2000);
} catch (err) {
button.innerHTML = '<i class="fas fa-times mr-1"></i>失败';
button.classList.add('text-[var(--error)]');
setTimeout(() => {
button.innerHTML = '<i class="fas fa-copy mr-1"></i>复制';
button.classList.remove('text-[var(--error)]');
}, 2000);
}
});
pre.appendChild(button);
});
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCodeCopy);
} else {
initCodeCopy();
}
// Re-initialize after dynamic content loads
const observer = new MutationObserver(() => {
initCodeCopy();
});
observer.observe(document.body, { childList: true, subtree: true });
})();
</script>

View File

@@ -0,0 +1,334 @@
---
import { API_BASE_URL, apiClient } from '../lib/api/client';
import type { Comment } from '../lib/api/client';
interface Props {
postSlug: string;
class?: string;
}
const { postSlug, class: className = '' } = Astro.props;
let comments: Comment[] = [];
let error: string | null = null;
try {
comments = await apiClient.getComments(postSlug, { approved: true });
} catch (e) {
error = e instanceof Error ? e.message : '加载评论失败';
console.error('Failed to fetch comments:', e);
}
function formatCommentDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
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' });
}
---
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-message"></i>
discussion buffer
</span>
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-comments"></i>
</span>
<div>
<h3 class="text-xl font-semibold text-[var(--title-color)]">评论终端</h3>
<p class="text-sm text-[var(--text-secondary)]">
当前缓冲区共有 {comments.length} 条已展示评论,新的留言提交后会进入审核队列。
</p>
</div>
</div>
</div>
<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>
</button>
</div>
<div id="comment-form-container" class="mt-6 hidden">
<form id="comment-form" class="terminal-panel-muted space-y-5">
<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>
</label>
<input
type="text"
name="nickname"
required
placeholder="anonymous_operator"
class="terminal-form-input"
/>
</div>
<div>
<label class="terminal-form-label">
email <span class="text-[var(--text-tertiary)] normal-case tracking-normal">(optional)</span>
</label>
<input
type="email"
name="email"
placeholder="you@example.com"
class="terminal-form-input"
/>
</div>
</div>
<div>
<label class="terminal-form-label">
message <span class="text-[var(--primary)]">*</span>
</label>
<textarea
name="content"
required
rows="6"
maxlength="500"
placeholder="$ echo 'Leave your thoughts here...'"
class="terminal-form-textarea resize-y"
></textarea>
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">max 500 chars</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>
</span>
<button type="button" id="cancel-reply" class="terminal-action-button">
<i class="fas fa-xmark"></i>
<span>cancel reply</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>
</button>
<button type="button" id="cancel-comment" class="terminal-action-button">
<i class="fas fa-ban"></i>
<span>close</span>
</button>
</div>
<div id="comment-message" class="hidden rounded-2xl border px-4 py-3 text-sm"></div>
</form>
</div>
<div id="comments-list" class="mt-8 space-y-4">
{error ? (
<div class="rounded-2xl border px-4 py-4 text-sm text-[var(--danger)]" style="border-color: color-mix(in oklab, var(--danger) 30%, var(--border-color)); background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));">
{error}
</div>
) : 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>
<h4 class="text-lg font-semibold text-[var(--title-color)]">暂无评论</h4>
<p class="text-sm leading-7 text-[var(--text-secondary)]">
当前还没有留言。可以打开上面的输入面板,作为第一个在这个终端缓冲区里发言的人。
</p>
</div>
</div>
) : (
comments.map(comment => (
<div
class="rounded-2xl border p-4"
data-comment-id={comment.id}
style="border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color)); background: linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 96%, transparent), color-mix(in oklab, var(--header-bg) 90%, transparent));"
>
<div class="flex gap-4">
<div class="shrink-0">
<div class="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]">
<i class="fas fa-user text-[var(--primary)]"></i>
</div>
</div>
<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="terminal-chip px-2.5 py-1 text-xs">
<i class="far fa-clock text-[var(--primary)]"></i>
{formatCommentDate(comment.created_at)}
</span>
</div>
<p class="text-sm leading-7 text-[var(--text-secondary)]">{comment.content}</p>
<div class="flex flex-wrap gap-2 text-xs">
<button
type="button"
class="reply-btn terminal-action-button px-3 py-2 text-xs"
data-author={comment.author}
data-id={comment.id}
>
<i class="fas fa-reply"></i>
<span>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>
</button>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
<script>
const wrapper = document.querySelector('.terminal-comments');
const toggleBtn = document.getElementById('toggle-comment-form');
const formContainer = document.getElementById('comment-form-container');
const cancelBtn = document.getElementById('cancel-comment');
const form = document.getElementById('comment-form') as HTMLFormElement | null;
const replyingTo = document.getElementById('replying-to');
const replyTarget = document.getElementById('reply-target');
const cancelReply = document.getElementById('cancel-reply');
const replyBtns = document.querySelectorAll('.reply-btn');
const messageBox = document.getElementById('comment-message');
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
const apiBase = wrapper?.getAttribute('data-api-base') || 'http://localhost:5150/api';
function showMessage(message: string, type: 'success' | 'error' | 'info') {
if (!messageBox) return;
messageBox.classList.remove(
'hidden',
'text-[var(--success)]',
'text-[var(--danger)]',
'text-[var(--primary)]'
);
if (type === 'success') {
messageBox.classList.add('text-[var(--success)]');
messageBox.setAttribute(
'style',
'border-color: color-mix(in oklab, var(--success) 28%, var(--border-color)); background: color-mix(in oklab, var(--success) 10%, var(--header-bg));'
);
} else if (type === 'error') {
messageBox.classList.add('text-[var(--danger)]');
messageBox.setAttribute(
'style',
'border-color: color-mix(in oklab, var(--danger) 28%, var(--border-color)); background: color-mix(in oklab, var(--danger) 10%, var(--header-bg));'
);
} else {
messageBox.classList.add('text-[var(--primary)]');
messageBox.setAttribute(
'style',
'border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color)); background: color-mix(in oklab, var(--primary) 10%, var(--header-bg));'
);
}
messageBox.textContent = message;
messageBox.classList.remove('hidden');
}
function resetReply() {
replyingTo?.classList.add('hidden');
replyingTo?.removeAttribute('data-reply-to');
}
toggleBtn?.addEventListener('click', () => {
formContainer?.classList.toggle('hidden');
if (!formContainer?.classList.contains('hidden')) {
form?.querySelector('textarea')?.focus();
}
});
cancelBtn?.addEventListener('click', () => {
formContainer?.classList.add('hidden');
resetReply();
});
replyBtns.forEach(btn => {
btn.addEventListener('click', () => {
const author = btn.getAttribute('data-author');
const commentId = btn.getAttribute('data-id');
if (replyingTo && replyTarget) {
replyingTo.classList.remove('hidden');
replyingTo.classList.add('flex');
replyTarget.textContent = author || '匿名';
replyingTo.setAttribute('data-reply-to', commentId || '');
}
formContainer?.classList.remove('hidden');
form?.querySelector('textarea')?.focus();
});
});
cancelReply?.addEventListener('click', () => {
replyingTo?.classList.remove('flex');
resetReply();
});
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const replyToId = replyingTo?.getAttribute('data-reply-to');
try {
showMessage('正在提交评论...', 'info');
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'),
replyTo: replyToId || null,
}),
});
if (!response.ok) {
throw new Error(await response.text());
}
form.reset();
replyingTo?.classList.remove('flex');
resetReply();
formContainer?.classList.add('hidden');
showMessage('评论已提交,审核通过后会显示在这里。', 'success');
} catch (error) {
showMessage(`提交失败:${error instanceof Error ? error.message : 'unknown error'}`, 'error');
}
});
document.querySelectorAll('.like-btn').forEach(btn => {
btn.addEventListener('click', () => {
const icon = btn.querySelector('i');
if (icon?.classList.contains('far')) {
icon.classList.replace('far', 'fas');
btn.classList.add('terminal-action-button-primary');
} else if (icon?.classList.contains('fas')) {
icon.classList.replace('fas', 'far');
btn.classList.remove('terminal-action-button-primary');
}
});
});
</script>

View File

@@ -0,0 +1,79 @@
---
import { terminalConfig } from '../lib/config/terminal';
import { DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import type { SiteSettings } from '../lib/types';
interface Props {
siteSettings?: SiteSettings;
}
const { siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
const social = siteSettings.social;
const currentYear = new Date().getFullYear();
---
<footer class="border-t border-[var(--border-color)]/70 mt-auto py-8">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="terminal-toolbar-shell">
<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>
</div>
<div class="flex flex-wrap items-center gap-3">
{terminalConfig.tools.map(tool => (
<a
href={tool.href}
class="terminal-toolbar-iconbtn"
title={tool.title}
>
<i class={`fas ${tool.icon}`}></i>
</a>
))}
</div>
<div class="flex flex-wrap items-center gap-3">
{social.github && (
<a
href={social.github}
target="_blank"
rel="noopener noreferrer"
class="terminal-toolbar-iconbtn"
aria-label="GitHub"
>
<i class="fab fa-github"></i>
</a>
)}
{social.twitter && (
<a
href={social.twitter}
target="_blank"
rel="noopener noreferrer"
class="terminal-toolbar-iconbtn"
aria-label="Twitter"
>
<i class="fab fa-twitter"></i>
</a>
)}
{social.email && (
<a
href={social.email}
class="terminal-toolbar-iconbtn"
aria-label="Email"
>
<i class="fas fa-envelope"></i>
</a>
)}
</div>
</div>
<div class="mt-4 border-t border-[var(--border-color)]/70 pt-4">
<p class="text-xs text-[var(--text-tertiary)] font-mono">
<span class="text-[var(--primary)]">user@{siteSettings.siteShortName.toLowerCase()}</span>:<span class="text-[var(--secondary)]">~</span>$ echo "{siteSettings.siteDescription}"
</p>
</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,258 @@
---
import { API_BASE_URL, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import type { SiteSettings } from '../lib/types';
interface Props {
class?: string;
siteSettings?: SiteSettings;
}
const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
---
<div
class={`terminal-friend-link-form ${className}`}
data-api-base={API_BASE_URL}
data-site-name={siteSettings.siteName}
data-site-url={siteSettings.siteUrl}
data-site-description={siteSettings.siteDescription}
>
<form id="friend-link-form" class="terminal-panel space-y-5">
<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>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
填写站点信息后会提交到后台审核,审核通过后前台会自动展示。
</p>
</div>
<div class="terminal-stat-pill self-start sm:self-auto">
<i class="fas fa-shield-alt text-[var(--primary)]"></i>
<span>后台审核后上线</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>
</label>
<input
type="text"
name="siteName"
required
placeholder="your-site-name"
class="terminal-form-input"
/>
</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>
</label>
<input
type="url"
name="siteUrl"
required
placeholder="https://example.com"
class="terminal-form-input"
/>
</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-image mr-1"></i>头像链接 <span class="text-[var(--text-tertiary)] text-xs">(可选)</span>
</label>
<input
type="url"
name="avatarUrl"
placeholder="https://example.com/avatar.png"
class="terminal-form-input"
/>
</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>
</label>
<div class="flex flex-wrap gap-3">
{['tech', 'life', 'design', 'other'].map(category => (
<label class="ui-filter-pill ui-filter-pill--amber cursor-pointer">
<input type="radio" name="category" value={category} class="sr-only" />
<i class="fas fa-angle-right text-[10px] opacity-70"></i>
<span class="text-sm">[{category}]</span>
</label>
))}
</div>
</div>
</div>
<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>
</label>
<textarea
name="description"
required
rows="3"
maxlength="200"
placeholder="describe your site..."
class="terminal-form-textarea resize-none"
></textarea>
<p class="text-xs text-[var(--text-tertiary)] mt-1 text-right">最多 200 字</p>
</div>
<div class="terminal-panel-muted flex items-start gap-3">
<input
type="checkbox"
name="hasReciprocal"
id="has-reciprocal"
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>
</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>本站信息:
</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)] 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>复制
</button>
</p>
<p class="flex items-center gap-2">
<span class="text-[var(--text-tertiary)]">链接:</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>复制
</button>
</p>
<p class="flex items-center gap-2">
<span class="text-[var(--text-tertiary)]">描述:</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>复制
</button>
</p>
</div>
</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>提交申请
</button>
<button
type="reset"
class="terminal-action-button"
>
<i class="fas fa-undo"></i>重置
</button>
</div>
<div id="form-message" class="hidden p-3 rounded-lg text-sm"></div>
</form>
</div>
<script>
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;
const reciprocalInfo = document.getElementById('reciprocal-info') as HTMLDivElement | null;
const messageDiv = document.getElementById('form-message') as HTMLDivElement | null;
const copyBtns = document.querySelectorAll('.copy-btn');
const apiBase = wrapper?.getAttribute('data-api-base') || 'http://localhost:5150/api';
reciprocalCheckbox?.addEventListener('change', () => {
reciprocalInfo?.classList.toggle('hidden', !reciprocalCheckbox.checked);
});
copyBtns.forEach(btn => {
btn.addEventListener('click', async () => {
const text = btn.getAttribute('data-text') || '';
await navigator.clipboard.writeText(text);
const originalHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i>已复制';
setTimeout(() => {
btn.innerHTML = originalHTML;
}, 1800);
});
});
function showMessage(message: string, type: 'success' | 'error' | 'info') {
if (!messageDiv) return;
messageDiv.classList.remove('hidden', 'bg-green-500/10', 'text-green-500', 'bg-red-500/10', 'text-red-500', 'bg-blue-500/10', 'text-blue-500');
if (type === 'success') {
messageDiv.classList.add('bg-green-500/10', 'text-green-500');
} else if (type === 'error') {
messageDiv.classList.add('bg-red-500/10', 'text-red-500');
} else {
messageDiv.classList.add('bg-blue-500/10', 'text-blue-500');
}
messageDiv.textContent = message;
messageDiv.classList.remove('hidden');
}
form?.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(form);
const hasReciprocal = formData.get('hasReciprocal') === 'on';
if (!hasReciprocal) {
showMessage('请先添加本站友链后再提交申请。', 'error');
return;
}
try {
showMessage('正在提交友链申请...', 'info');
const response = await fetch(`${apiBase}/friend_links`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
siteName: formData.get('siteName'),
siteUrl: formData.get('siteUrl'),
avatarUrl: formData.get('avatarUrl'),
category: formData.get('category'),
description: formData.get('description'),
}),
});
if (!response.ok) {
throw new Error(await response.text());
}
form.reset();
reciprocalInfo?.classList.add('hidden');
showMessage('友链申请已提交,我们会尽快审核。', 'success');
} catch (error) {
showMessage(`提交失败:${error instanceof Error ? error.message : 'unknown error'}`, 'error');
}
});
</script>
<style>
.terminal-friend-link-form input:focus,
.terminal-friend-link-form textarea:focus,
.terminal-friend-link-form select:focus {
box-shadow: 0 0 0 2px var(--primary-alpha, rgba(99, 102, 241, 0.2));
}
input[type="checkbox"] {
accent-color: var(--primary);
}
</style>

View File

@@ -0,0 +1,70 @@
---
import type { FriendLink } from '../lib/types';
interface Props {
friend: FriendLink;
}
const { friend } = Astro.props;
---
<a
href={friend.url}
target="_blank"
rel="noopener noreferrer"
class="terminal-panel group flex h-full items-start gap-4 p-4 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
>
<div class="shrink-0">
{friend.avatar ? (
<div class="relative w-12 h-12">
<img
src={friend.avatar}
alt={friend.name}
class="w-12 h-12 rounded-2xl object-cover border border-[var(--border-color)] bg-[var(--code-bg)]"
loading="lazy"
decoding="async"
onerror="this.style.display='none'; this.parentElement?.querySelector('[data-avatar-fallback]')?.classList.remove('hidden');"
/>
<div
data-avatar-fallback
class="hidden w-12 h-12 rounded-2xl bg-[var(--code-bg)] border border-[var(--border-color)] items-center justify-center text-sm font-bold text-[var(--primary)]"
>
{friend.name.charAt(0).toUpperCase()}
</div>
</div>
) : (
<div class="w-12 h-12 rounded-2xl bg-[var(--code-bg)] border border-[var(--border-color)] flex items-center justify-center text-sm font-bold text-[var(--primary)]">
{friend.name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<h4 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-base">
{friend.name}
</h4>
<i class="fas fa-external-link-alt text-xs text-[var(--text-tertiary)] opacity-0 group-hover:opacity-100 transition-opacity"></i>
</div>
{friend.description && (
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-6">{friend.description}</p>
)}
<div class="mt-3 flex items-center justify-between gap-3">
{friend.category ? (
<span class="terminal-chip text-xs py-1 px-2.5">
<i class="fas fa-folder text-[10px]"></i>
<span>{friend.category}</span>
</span>
) : (
<span class="text-xs text-[var(--text-tertiary)] font-mono">external link</span>
)}
<span class="terminal-link-arrow">
<span>访问</span>
<i class="fas fa-arrow-up-right-from-square text-xs"></i>
</span>
</div>
</div>
</a>

View File

@@ -0,0 +1,507 @@
---
import { terminalConfig } from '../lib/config/terminal';
import type { SiteSettings } from '../lib/types';
interface Props {
siteName?: string;
siteSettings?: SiteSettings;
}
const {
siteName = Astro.props.siteSettings?.siteShortName || terminalConfig.branding?.shortName || 'Termi'
} = Astro.props;
const navItems = terminalConfig.navLinks;
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);">
<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">
<div class="flex items-center gap-3">
<a href="/" class="terminal-toolbar-module shrink-0 min-w-[11.5rem] hover:border-[var(--primary)] transition-all">
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8 text-[var(--primary)]">
<i class="fas fa-terminal text-lg"></i>
</span>
<span>
<span class="terminal-toolbar-label block">root@termi</span>
<span class="mt-1 block text-lg font-bold text-[var(--title-color)]">{siteName}</span>
</span>
</a>
<div class="hidden xl:flex terminal-toolbar-module min-w-[15rem]">
<div class="min-w-0 flex-1">
<div class="terminal-toolbar-label">playerctl</div>
<div class="mt-1 flex items-center gap-2">
<button id="music-prev" class="terminal-toolbar-iconbtn">
<i class="fas fa-step-backward text-xs"></i>
</button>
<button id="music-play" class="terminal-toolbar-iconbtn bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));">
<i class="fas fa-play text-xs" id="music-play-icon"></i>
</button>
<button id="music-next" class="terminal-toolbar-iconbtn">
<i class="fas fa-step-forward text-xs"></i>
</button>
<span class="min-w-0 flex-1 truncate text-xs font-mono text-[var(--text-secondary)]" id="music-title">
ギターと孤独と蒼い惑星
</span>
<button id="music-volume" class="terminal-toolbar-iconbtn">
<i class="fas fa-volume-up text-xs"></i>
</button>
</div>
</div>
</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>
<input
type="text"
id="search-input"
placeholder="'关键词'"
class="terminal-console-input"
/>
<span 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>
</button>
</div>
<div
id="search-results"
class="hidden absolute right-0 top-[calc(100%+12px)] w-[26rem] overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_20px_40px_rgba(15,23,42,0.08)]"
></div>
</div>
<button
id="theme-toggle"
class="theme-toggle terminal-toolbar-iconbtn h-11 w-11 shrink-0"
aria-label="切换主题"
title="切换主题"
>
<i id="theme-icon" class="fas fa-moon text-[var(--primary)]"></i>
</button>
<button
id="mobile-menu-btn"
class="lg:hidden terminal-toolbar-iconbtn h-11 w-11 shrink-0"
aria-label="Toggle menu"
>
<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>
<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));
return (
<a
href={item.href}
class:list={[
'terminal-nav-link',
isActive && 'is-active'
]}
>
<i class={`fas ${item.icon} text-xs`}></i>
<span>{item.text}</span>
</a>
);
})}
</nav>
</div>
</div>
</div>
<!-- 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>
{navItems.map(item => (
<a
href={item.href}
class:list={[
'terminal-nav-link flex',
currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href))
? 'is-active'
: ''
]}
>
<i class={`fas ${item.icon} w-5`}></i>
<span>{item.text}</span>
</a>
))}
</div>
</div>
</header>
<script is:inline>
// Theme Toggle - simplified vanilla JS
function initThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
if (!themeToggle || !themeIcon) {
console.error('[Theme] Elements not found');
return;
}
console.log('[Theme] Initializing toggle button');
function updateThemeIcon(isDark) {
console.log('[Theme] Updating icon, isDark:', isDark);
if (isDark) {
themeIcon.className = 'fas fa-sun text-[var(--secondary)]';
} else {
themeIcon.className = 'fas fa-moon text-[var(--primary)]';
}
}
themeToggle.addEventListener('click', function() {
console.log('[Theme] Button clicked');
const root = document.documentElement;
const hasDark = root.classList.contains('dark');
console.log('[Theme] Current hasDark:', hasDark);
if (hasDark) {
root.classList.remove('dark');
root.classList.add('light');
localStorage.setItem('theme', 'light');
updateThemeIcon(false);
} else {
root.classList.remove('light');
root.classList.add('dark');
localStorage.setItem('theme', 'dark');
updateThemeIcon(true);
}
});
// Initialize icon based on current theme
const isDark = document.documentElement.classList.contains('dark');
updateThemeIcon(isDark);
}
// Run immediately if DOM is ready, otherwise wait
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initThemeToggle);
} else {
initThemeToggle();
}
// Mobile Menu
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
const mobileMenu = document.getElementById('mobile-menu');
const mobileSearchInput = document.getElementById('mobile-search-input');
const mobileSearchBtn = document.getElementById('mobile-search-btn');
mobileMenuBtn?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
// Music Player with actual audio
const musicPlay = document.getElementById('music-play');
const musicPlayIcon = document.getElementById('music-play-icon');
const musicTitle = document.getElementById('music-title');
const musicPrev = document.getElementById('music-prev');
const musicNext = document.getElementById('music-next');
const musicVolume = document.getElementById('music-volume');
// Playlist - Using placeholder audio URLs (replace with actual music URLs)
const playlist = [
{ title: 'ギターと孤独と蒼い惑星', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' },
{ title: '星座になれたら', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3' },
{ title: 'あのバンド', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3' }
];
let currentSongIndex = 0;
let isPlaying = false;
let audio = null;
let volume = 0.5;
function initAudio() {
if (!audio) {
audio = new Audio();
audio.volume = volume;
audio.addEventListener('ended', () => {
playNext();
});
}
}
function updateTitle() {
if (musicTitle) {
musicTitle.textContent = playlist[currentSongIndex].title;
}
}
function playSong() {
initAudio();
if (audio.src !== playlist[currentSongIndex].url) {
audio.src = playlist[currentSongIndex].url;
}
audio.play().catch(err => console.log('Audio play failed:', err));
isPlaying = true;
if (musicPlayIcon) {
musicPlayIcon.className = 'fas fa-pause text-xs';
}
if (musicTitle) {
musicTitle.classList.add('text-[var(--primary)]');
musicTitle.classList.remove('text-[var(--text-secondary)]');
}
}
function pauseSong() {
if (audio) {
audio.pause();
}
isPlaying = false;
if (musicPlayIcon) {
musicPlayIcon.className = 'fas fa-play text-xs';
}
if (musicTitle) {
musicTitle.classList.remove('text-[var(--primary)]');
musicTitle.classList.add('text-[var(--text-secondary)]');
}
}
function togglePlay() {
if (isPlaying) {
pauseSong();
} else {
playSong();
}
}
function playNext() {
currentSongIndex = (currentSongIndex + 1) % playlist.length;
updateTitle();
if (isPlaying) {
playSong();
}
}
function playPrev() {
currentSongIndex = (currentSongIndex - 1 + playlist.length) % playlist.length;
updateTitle();
if (isPlaying) {
playSong();
}
}
function toggleMute() {
if (audio) {
audio.muted = !audio.muted;
if (musicVolume) {
musicVolume.innerHTML = audio.muted ?
'<i class="fas fa-volume-mute text-xs"></i>' :
'<i class="fas fa-volume-up text-xs"></i>';
}
}
}
musicPlay?.addEventListener('click', togglePlay);
musicNext?.addEventListener('click', playNext);
musicPrev?.addEventListener('click', playPrev);
musicVolume?.addEventListener('click', toggleMute);
// Initialize title
updateTitle();
// Search functionality
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const searchResults = document.getElementById('search-results');
const searchApiBase = 'http://localhost:5150/api';
let searchTimer = null;
function escapeHtml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function highlightText(value, query) {
const escapedValue = escapeHtml(value || '');
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return escapedValue;
}
const escapedQuery = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return escapedValue.replace(
new RegExp(`(${escapedQuery})`, 'ig'),
'<mark class="rounded-sm border border-[var(--border-color)] bg-[var(--primary-light)] px-1 text-[var(--title-color)]">$1</mark>'
);
}
function hideSearchResults() {
if (!searchResults) return;
searchResults.classList.add('hidden');
searchResults.innerHTML = '';
}
function renderSearchResults(query, results, state = 'ready') {
if (!searchResults) return;
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> ...
</div>
`;
searchResults.classList.remove('hidden');
return;
}
if (state === 'error') {
searchResults.innerHTML = `
<div class="px-4 py-4 text-sm text-[var(--danger)]">
搜索失败,请稍后再试。
</div>
`;
searchResults.classList.remove('hidden');
return;
}
if (!results.length) {
searchResults.innerHTML = `
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
没有找到和 <span class="text-[var(--primary)] font-mono">${escapeHtml(query)}</span> 相关的内容。
</div>
`;
searchResults.classList.remove('hidden');
return;
}
const itemsHtml = results.map((item) => {
const tags = Array.isArray(item.tags) ? item.tags.slice(0, 4) : [];
const tagHtml = tags.length
? `<div class="mt-2 flex flex-wrap gap-2">${tags
.map((tag) => `<span class="rounded-full border border-[var(--border-color)] px-2 py-0.5 text-xs text-[var(--text-secondary)]">#${highlightText(tag, query)}</span>`)
.join('')}</div>`
: '';
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-[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>
${tagHtml}
</a>
`;
}).join('');
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)]">
实时搜索结果
</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>
</div>
`;
searchResults.classList.remove('hidden');
}
async function runLiveSearch(query) {
if (!query) {
hideSearchResults();
return;
}
renderSearchResults(query, [], 'loading');
try {
const response = await fetch(`${searchApiBase}/search?q=${encodeURIComponent(query)}&limit=6`);
if (!response.ok) {
throw new Error(`Search failed: ${response.status}`);
}
const results = await response.json();
renderSearchResults(query, Array.isArray(results) ? results : []);
} catch (error) {
console.error('Live search failed:', error);
renderSearchResults(query, [], 'error');
}
}
function submitSearch() {
const query = searchInput && 'value' in searchInput ? searchInput.value.trim() : '';
if (query) {
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
}
}
searchBtn?.addEventListener('click', function() {
submitSearch();
});
mobileSearchBtn?.addEventListener('click', function() {
const query = mobileSearchInput && 'value' in mobileSearchInput ? mobileSearchInput.value.trim() : '';
if (query) {
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
}
});
searchInput?.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
submitSearch();
}
});
mobileSearchInput?.addEventListener('keydown', function(e) {
if (e.key === 'Enter') {
const query = this.value.trim();
if (query) {
window.location.href = `/articles?search=${encodeURIComponent(query)}`;
}
}
});
searchInput?.addEventListener('input', function() {
const query = this.value.trim();
if (searchTimer) {
clearTimeout(searchTimer);
}
searchTimer = setTimeout(() => {
runLiveSearch(query);
}, 180);
});
searchInput?.addEventListener('focus', function() {
const query = this.value.trim();
if (query) {
runLiveSearch(query);
}
});
document.addEventListener('click', function(event) {
const target = event.target;
if (
searchResults &&
!searchResults.contains(target) &&
target !== searchInput &&
target !== searchBtn &&
!searchBtn?.contains(target)
) {
hideSearchResults();
}
});
</script>

View File

@@ -0,0 +1,141 @@
---
// Image Lightbox Component
---
<div id="lightbox" class="fixed inset-0 z-[200] hidden bg-black/90 backdrop-blur-sm">
<div class="flex items-center justify-center min-h-screen p-4">
<button
id="lightbox-close"
class="absolute top-4 right-4 w-10 h-10 rounded-full bg-[var(--header-bg)] text-[var(--text-secondary)] hover:text-[var(--primary)] transition-colors flex items-center justify-center"
>
<i class="fas fa-times text-lg"></i>
</button>
<button
id="lightbox-prev"
class="absolute left-4 w-10 h-10 rounded-full bg-[var(--header-bg)] text-[var(--text-secondary)] hover:text-[var(--primary)] transition-colors flex items-center justify-center"
>
<i class="fas fa-chevron-left"></i>
</button>
<img
id="lightbox-image"
src=""
alt=""
class="max-w-full max-h-[85vh] object-contain rounded-lg"
/>
<button
id="lightbox-next"
class="absolute right-4 w-10 h-10 rounded-full bg-[var(--header-bg)] text-[var(--text-secondary)] hover:text-[var(--primary)] transition-colors flex items-center justify-center"
>
<i class="fas fa-chevron-right"></i>
</button>
</div>
</div>
<script is:inline>
(function() {
const lightbox = document.getElementById('lightbox');
const lightboxImage = document.getElementById('lightbox-image');
const lightboxClose = document.getElementById('lightbox-close');
const lightboxPrev = document.getElementById('lightbox-prev');
const lightboxNext = document.getElementById('lightbox-next');
if (!lightbox || !lightboxImage) return;
let images = [];
let currentIndex = 0;
// Initialize lightbox for all article images
function initLightbox() {
const content = document.querySelector('.article-content');
if (!content) return;
images = Array.from(content.querySelectorAll('img'));
images.forEach((img, index) => {
img.style.cursor = 'zoom-in';
img.addEventListener('click', () => openLightbox(index));
});
// Update navigation visibility
updateNavVisibility();
}
function openLightbox(index) {
currentIndex = index;
updateImage();
lightbox.classList.remove('hidden');
document.body.style.overflow = 'hidden';
updateNavVisibility();
}
function closeLightbox() {
lightbox.classList.add('hidden');
document.body.style.overflow = '';
}
function updateImage() {
if (images[currentIndex]) {
lightboxImage.src = images[currentIndex].src;
lightboxImage.alt = images[currentIndex].alt || '';
}
}
function updateNavVisibility() {
if (lightboxPrev) {
lightboxPrev.style.display = images.length > 1 ? 'flex' : 'none';
}
if (lightboxNext) {
lightboxNext.style.display = images.length > 1 ? 'flex' : 'none';
}
}
function showPrev() {
currentIndex = (currentIndex - 1 + images.length) % images.length;
updateImage();
}
function showNext() {
currentIndex = (currentIndex + 1) % images.length;
updateImage();
}
// Event listeners
if (lightboxClose) {
lightboxClose.addEventListener('click', closeLightbox);
}
if (lightboxPrev) {
lightboxPrev.addEventListener('click', showPrev);
}
if (lightboxNext) {
lightboxNext.addEventListener('click', showNext);
}
// Close on backdrop click
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox) {
closeLightbox();
}
});
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (lightbox.classList.contains('hidden')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft' && images.length > 1) showPrev();
if (e.key === 'ArrowRight' && images.length > 1) showNext();
});
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initLightbox);
} else {
initLightbox();
}
})();
</script>

View File

@@ -0,0 +1,112 @@
---
import type { Post } from '../lib/types';
import TerminalButton from './ui/TerminalButton.astro';
import CodeBlock from './CodeBlock.astro';
import { resolveFileRef, getPostTypeColor } from '../lib/utils';
interface Props {
post: Post;
selectedTag?: string;
highlightTerm?: string;
}
const { post, selectedTag = '', highlightTerm = '' } = Astro.props;
const typeColor = getPostTypeColor(post.type);
const escapeHtml = (value: string) =>
value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
const highlightText = (value: string, query: string) => {
const escapedValue = escapeHtml(value || '');
const normalizedQuery = query.trim();
if (!normalizedQuery) {
return escapedValue;
}
const escapedQuery = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return escapedValue.replace(
new RegExp(`(${escapedQuery})`, 'ig'),
'<mark class="rounded px-1 bg-[var(--primary-light)] text-[var(--title-color)]">$1</mark>'
);
};
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
---
<article
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)}
/>
</div>
<p class="text-sm text-[var(--text-secondary)]">
{post.date} | 阅读时间: {post.readTime}
</p>
</div>
<span class="terminal-chip shrink-0 text-xs py-1 px-2.5">
#{post.category}
</span>
</div>
<p class="relative z-10 pl-3 text-[var(--text-secondary)] mb-4 leading-7" set:html={highlightText(post.description, highlightTerm)} />
{post.code && (
<div class="relative z-10 mb-3">
<CodeBlock code={post.code} language={post.language || 'bash'} />
</div>
)}
{post.images && post.images.length > 0 && (
<div class="relative z-10 mb-3 grid gap-2" class:list={[
post.images.length === 1 ? 'grid-cols-1' :
post.images.length === 2 ? 'grid-cols-2' :
post.images.length >= 3 ? 'grid-cols-2 md:grid-cols-3' :
'grid-cols-1'
]}>
{post.images.map((img, index) => (
<div class:list={[
"relative overflow-hidden rounded-lg border border-[var(--border-color)]",
post.images && post.images.length === 1 ? 'aspect-video' :
'aspect-square'
]}>
<img
src={resolveFileRef(img)}
alt={`${post.title} - ${index + 1}`}
loading="lazy"
class="w-full h-full object-cover hover:scale-105 transition-transform"
/>
</div>
))}
</div>
)}
<!-- Tags -->
<div class="relative z-10 pl-3 flex flex-wrap gap-2">
{post.tags?.map(tag => (
<TerminalButton
variant={normalizedSelectedTag === tag.trim().toLowerCase() ? 'primary' : 'neutral'}
size="xs"
href={`/tags?tag=${encodeURIComponent(tag)}`}
>
<i class="fas fa-hashtag text-xs"></i>
<span set:html={highlightText(tag, highlightTerm)} />
</TerminalButton>
))}
</div>
</article>

View File

@@ -0,0 +1,45 @@
---
// Reading Progress Bar Component
---
<div id="reading-progress" class="fixed top-0 left-0 h-1 bg-[var(--primary)] z-[100] transition-all duration-150" style="width: 0%"></div>
<script is:inline>
(function() {
function updateProgress() {
const progressBar = document.getElementById('reading-progress');
if (!progressBar) return;
const content = document.querySelector('.article-content');
if (!content) {
progressBar.style.width = '0%';
return;
}
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const contentTop = content.offsetTop;
const contentHeight = content.offsetHeight;
const windowHeight = window.innerHeight;
// Calculate reading progress
const scrollableDistance = contentHeight - windowHeight + contentTop;
const currentScroll = scrollTop - contentTop;
let progress = 0;
if (currentScroll > 0) {
progress = Math.min(100, Math.max(0, (currentScroll / scrollableDistance) * 100));
}
progressBar.style.width = progress + '%';
}
// Update on scroll
window.addEventListener('scroll', updateProgress, { passive: true });
// Update on resize
window.addEventListener('resize', updateProgress);
// Initial update
updateProgress();
})();
</script>

View File

@@ -0,0 +1,98 @@
---
import { apiClient } from '../lib/api/client';
interface Props {
currentSlug: string;
currentCategory: string;
currentTags: string[];
}
const { currentSlug, currentCategory, currentTags } = Astro.props;
const allPosts = await apiClient.getPosts();
const relatedPosts = allPosts
.filter(post => post.slug !== currentSlug)
.map(post => {
let score = 0;
if (post.category === currentCategory) {
score += 3;
}
const sharedTags = post.tags.filter(tag => currentTags.includes(tag));
score += sharedTags.length * 2;
return { ...post, score, sharedTags };
})
.sort((a, b) => b.score - a.score)
.slice(0, 3);
---
{relatedPosts.length > 0 && (
<section class="terminal-panel mt-8">
<div class="space-y-5">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-diagram-project"></i>
related traces
</span>
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-share-nodes"></i>
</span>
<div>
<h3 class="text-xl font-semibold text-[var(--title-color)]">相关文章</h3>
<p class="text-sm text-[var(--text-secondary)]">
基于当前分类与标签关联出的相近内容,延续同一条阅读链路。
</p>
</div>
</div>
</div>
<span class="terminal-stat-pill">
<i class="fas fa-wave-square text-[var(--primary)]"></i>
{relatedPosts.length} linked
</span>
</div>
<div class="grid gap-4 md:grid-cols-3">
{relatedPosts.map(post => (
<a
href={`/articles/${post.slug}`}
class="terminal-panel-muted group flex h-full flex-col gap-3 p-4 transition-all hover:-translate-y-1 hover:border-[var(--primary)]"
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-2">
<span class="terminal-chip px-2.5 py-1 text-xs">
<span class={`h-2.5 w-2.5 rounded-full ${post.type === 'article' ? 'bg-[var(--primary)]' : 'bg-[var(--secondary)]'}`}></span>
{post.type}
</span>
<h4 class="text-base font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
{post.title}
</h4>
</div>
<i class="fas fa-arrow-up-right-from-square text-sm text-[var(--text-tertiary)] transition-colors group-hover:text-[var(--primary)]"></i>
</div>
<p class="text-sm leading-7 text-[var(--text-secondary)]">{post.description}</p>
<div class="mt-auto flex flex-wrap items-center gap-2 pt-2">
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
<i class="far fa-calendar text-[var(--primary)]"></i>
{post.date}
</span>
{post.sharedTags.length > 0 && (
<span class="terminal-chip px-2.5 py-1 text-xs">
<i class="fas fa-hashtag text-[var(--primary)]"></i>
{post.sharedTags.map(tag => `#${tag}`).join(' ')}
</span>
)}
</div>
</a>
))}
</div>
</div>
</section>
)}

View File

@@ -0,0 +1,21 @@
---
import type { SystemStat } from '../lib/types';
import InfoTile from './ui/InfoTile.astro';
interface Props {
stats: SystemStat[];
}
const { stats } = Astro.props;
---
<ul class="space-y-3 font-mono text-sm">
{stats.map(stat => (
<li>
<InfoTile layout="row" tone="neutral">
<span class="text-[var(--text-secondary)] uppercase tracking-[0.18em] text-[11px]">{stat.label}</span>
<span class="text-[var(--title-color)] font-bold text-base">{stat.value}</span>
</InfoTile>
</li>
))}
</ul>

View File

@@ -0,0 +1,97 @@
---
// Table of Contents Component - Extracts headings from article content
---
<aside id="toc-container" class="hidden w-full shrink-0 lg:block lg:w-72">
<div class="terminal-panel-muted sticky top-24 space-y-4">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-terminal"></i>
nav stack
</span>
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-list-ul"></i>
</span>
<div>
<h3 class="text-base font-semibold text-[var(--title-color)]">目录</h3>
<p class="text-xs leading-6 text-[var(--text-secondary)]">
实时跟踪当前文档的标题节点,像终端侧栏一样快速跳转。
</p>
</div>
</div>
</div>
<nav id="toc-nav" class="space-y-2 max-h-[calc(100vh-240px)] overflow-y-auto pr-1 text-sm">
<!-- TOC items will be generated by JavaScript -->
</nav>
</div>
</aside>
<script is:inline>
(function() {
function generateTOC() {
const content = document.querySelector('.article-content');
if (!content) return;
const headings = content.querySelectorAll('h2, h3');
const tocNav = document.getElementById('toc-nav');
if (!tocNav || headings.length === 0) {
const container = document.getElementById('toc-container');
if (container) container.style.display = 'none';
return;
}
tocNav.innerHTML = '';
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = `heading-${index}`;
}
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.className = `terminal-nav-link flex w-full items-center justify-between ${
heading.tagName === 'H3' ? 'pl-8 text-xs' : 'text-sm'
}`;
link.innerHTML = `
<span class="truncate">${heading.textContent || ''}</span>
<i class="fas fa-angle-right text-[10px] opacity-60"></i>
`;
link.addEventListener('click', (e) => {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
tocNav.appendChild(link);
});
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const links = tocNav.querySelectorAll('a');
links.forEach(link => {
link.classList.remove('is-active');
if (link.getAttribute('href') === `#${entry.target.id}`) {
link.classList.add('is-active');
}
});
}
});
},
{ rootMargin: '-20% 0px -75% 0px' }
);
headings.forEach(heading => observer.observe(heading));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', generateTOC);
} else {
generateTOC();
}
})();
</script>

View File

@@ -0,0 +1,28 @@
---
import type { TechStackItem } from '../lib/types';
import InfoTile from './ui/InfoTile.astro';
interface Props {
items: TechStackItem[];
}
const { items } = Astro.props;
---
<ul class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{items.map(item => (
<li>
<InfoTile layout="grid" tone="blue">
<span class="flex h-8 w-8 items-center justify-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
<i class="fas fa-code text-xs"></i>
</span>
<span class="min-w-0 flex-1">
<span class="block text-[var(--text)] text-sm font-medium">{item.name}</span>
{item.level && (
<span class="block text-xs text-[var(--text-tertiary)] mt-0.5">{item.level}</span>
)}
</span>
</InfoTile>
</li>
))}
</ul>

View File

@@ -0,0 +1,38 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
let showButton = false;
const scrollThreshold = 300;
onMount(() => {
const handleScroll = () => {
showButton = window.scrollY > scrollThreshold;
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
});
function scrollToTop() {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
}
</script>
{#if showButton}
<div
class="fixed bottom-5 right-5 z-50"
transition:fade={{ duration: 200 }}
>
<button
on:click={scrollToTop}
class="flex items-center gap-1.5 px-3 py-2 rounded-lg border border-[var(--primary)] bg-[var(--primary-light)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-[var(--terminal-bg)] transition-all text-sm font-mono"
>
<i class="fas fa-arrow-up"></i>
<span>top</span>
</button>
</div>
{/if}

View File

@@ -0,0 +1,60 @@
<script lang="ts">
import { onMount } from 'svelte';
let isDark = false;
onMount(() => {
console.log('[ThemeToggle] onMount');
// Check for saved theme preference or system preference
const savedTheme = localStorage.getItem('theme');
console.log('[ThemeToggle] savedTheme:', savedTheme);
if (savedTheme) {
isDark = savedTheme === 'dark';
} else {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
console.log('[ThemeToggle] initial isDark:', isDark);
updateTheme();
// Listen for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem('theme')) {
isDark = e.matches;
updateTheme();
}
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
});
function updateTheme() {
const root = document.documentElement;
if (isDark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
function toggleTheme() {
isDark = !isDark;
updateTheme();
}
</script>
<button
onclick={toggleTheme}
class="theme-toggle p-2 rounded-lg border border-[var(--border-color)] hover:bg-[var(--header-bg)] transition-all"
aria-label={isDark ? '切换到亮色模式' : '切换到暗色模式'}
title={isDark ? '切换到亮色模式' : '切换到暗色模式'}
>
{#if isDark}
<i class="fas fa-sun text-[var(--secondary)]"></i>
{:else}
<i class="fas fa-moon text-[var(--primary)]"></i>
{/if}
</button>

View File

@@ -0,0 +1,146 @@
---
interface Props {
command: string;
path?: string;
clickable?: boolean;
href?: string;
typing?: boolean;
}
const { command, path = '~/', clickable = false, href = '/', typing = true } = Astro.props;
const uniqueId = Math.random().toString(36).slice(2, 11);
---
<div class:list={['command-prompt', { clickable }]} data-command={command} data-typing={typing} data-id={uniqueId}>
{clickable ? (
<a href={href} class="prompt-link">
<span class="prompt">user@blog</span>
<span class="separator">:</span>
<span class="path">{path}</span>
<span class="suffix">$</span>
<span class="command-text ml-2" id={`cmd-${uniqueId}`}></span>
<span class="cursor" id={`cursor-${uniqueId}`}>_</span>
</a>
) : (
<>
<span class="prompt">user@blog</span>
<span class="separator">:</span>
<span class="path">{path}</span>
<span class="suffix">$</span>
<span class="command-text ml-2" id={`cmd-${uniqueId}`}></span>
<span class="cursor" id={`cursor-${uniqueId}`}>_</span>
</>
)}
</div>
<script is:inline>
(function() {
const prompts = document.querySelectorAll('[data-command]:not([data-typed])');
prompts.forEach(function(el) {
// Mark as processed immediately
el.setAttribute('data-typed', 'true');
const command = el.getAttribute('data-command');
const typing = el.getAttribute('data-typing') === 'true';
const id = el.getAttribute('data-id');
const cmdEl = document.getElementById('cmd-' + id);
const cursorEl = document.getElementById('cursor-' + id);
if (!cmdEl || !command) return;
if (typing) {
// Typewriter effect - characters appear one by one
let i = 0;
cmdEl.textContent = '';
cursorEl.style.animation = 'none';
cursorEl.style.opacity = '1';
function typeChar() {
if (i < command.length) {
cmdEl.textContent += command.charAt(i);
i++;
setTimeout(typeChar, 80 + Math.random() * 40); // Random delay for realistic effect
} else {
// Start cursor blinking after typing completes
cursorEl.style.animation = 'blink 1s infinite';
}
}
// Start typing after a small delay
setTimeout(typeChar, 300);
} else {
// Show all at once
cmdEl.textContent = command;
}
});
})();
</script>
<style>
.command-prompt {
display: flex;
align-items: center;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.875rem;
line-height: 1.5;
margin-bottom: 0.5rem;
}
.prompt {
color: var(--primary);
}
.separator {
color: var(--text);
}
.path {
color: var(--secondary);
}
.suffix {
color: var(--text);
margin-left: 0.25rem;
}
.command-text {
color: var(--secondary);
min-height: 1.2em;
}
.cursor {
color: var(--text);
margin-left: 0.25rem;
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.clickable {
cursor: pointer;
}
.prompt-link {
display: flex;
align-items: center;
text-decoration: none;
color: inherit;
transition: all 0.3s ease;
}
.clickable:hover .prompt-link {
transform: translateX(0.25rem);
}
.clickable:hover .prompt {
color: var(--secondary);
}
.clickable:hover .command-text {
color: var(--primary);
}
</style>

View File

@@ -0,0 +1,33 @@
---
interface Props {
href?: string;
active?: boolean;
tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral';
class?: string;
}
const {
href,
active = false,
tone = 'neutral',
class: className = '',
...rest
} = Astro.props;
const classes = [
'ui-filter-pill',
`ui-filter-pill--${tone}`,
active && 'is-active',
className,
].filter(Boolean).join(' ');
---
{href ? (
<a href={href} class={classes} {...rest}>
<slot />
</a>
) : (
<button type="button" class={classes} {...rest}>
<slot />
</button>
)}

View File

@@ -0,0 +1,35 @@
---
interface Props {
href?: string;
tone?: 'blue' | 'amber' | 'teal' | 'violet' | 'neutral';
layout?: 'row' | 'grid' | 'stack';
class?: string;
target?: string;
rel?: string;
}
const {
href,
tone = 'neutral',
layout = 'grid',
class: className = '',
...rest
} = Astro.props;
const classes = [
'ui-info-tile',
`ui-info-tile--${tone}`,
`ui-info-tile--${layout}`,
className,
].filter(Boolean).join(' ');
---
{href ? (
<a href={href} class={classes} {...rest}>
<slot />
</a>
) : (
<div class={classes} {...rest}>
<slot />
</div>
)}

View File

@@ -0,0 +1,44 @@
---
interface Props {
variant?: 'primary' | 'secondary' | 'neutral';
size?: 'xs' | 'sm' | 'md' | 'lg';
href?: string;
class?: string;
onclick?: string;
}
const {
variant = 'neutral',
size = 'md',
href,
onclick,
class: className = ''
} = Astro.props;
const baseStyles = 'inline-flex items-center gap-1.5 rounded-lg font-mono transition-all duration-300';
const variantStyles = {
primary: 'border border-[var(--primary)] bg-[var(--primary-light)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-[var(--terminal-bg)]',
secondary: 'border border-[var(--secondary)] bg-[var(--secondary-light)] text-[var(--secondary)] hover:bg-[var(--secondary)] hover:text-[var(--terminal-bg)]',
neutral: 'border border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text)] hover:border-[var(--primary)] hover:text-[var(--primary)]'
};
const sizeStyles = {
xs: 'px-2 py-1 text-xs',
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
const classes = `${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`;
---
{href ? (
<a href={href} class={classes} onclick={onclick}>
<slot />
</a>
) : (
<button class={classes} onclick={onclick}>
<slot />
</button>
)}

View File

@@ -0,0 +1,34 @@
---
interface Props {
title?: string;
showControls?: boolean;
class?: string;
}
const {
title = '~/blog',
showControls = true,
class: className = ''
} = Astro.props;
---
<div class={`terminal-window rounded-lg overflow-hidden border border-[var(--terminal-border)] bg-[var(--terminal-bg)] ${className}`}>
<!-- Window Header -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--terminal-border)] bg-[var(--header-bg)]">
<div class="flex items-center gap-2">
{showControls && (
<div class="flex items-center gap-1.5">
<span class="w-3 h-3 rounded-full bg-[#ff5f56]"></span>
<span class="w-3 h-3 rounded-full bg-[#ffbd2e]"></span>
<span class="w-3 h-3 rounded-full bg-[#27c93f]"></span>
</div>
)}
<span class="ml-2 text-sm text-[var(--text-secondary)] font-mono">{title}</span>
</div>
</div>
<!-- Window Content -->
<div class="p-4">
<slot />
</div>
</div>

View File

@@ -0,0 +1,16 @@
---
interface Props {
href: string;
text: string;
}
const { href, text } = Astro.props;
---
<a
href={href}
class="inline-flex items-center gap-1.5 text-sm font-mono text-[var(--primary)] hover:underline transition-all"
>
<span>{text}</span>
<i class="fas fa-arrow-right text-xs"></i>
</a>