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

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

43
frontend/README.md Normal file
View File

@@ -0,0 +1,43 @@
# Astro Starter Kit: Minimal
```sh
npm create astro@latest -- --template minimal
```
> 🧑‍🚀 **Seasoned astronaut?** Delete this file. Have fun!
## 🚀 Project Structure
Inside of your Astro project, you'll see the following folders and files:
```text
/
├── public/
├── src/
│ └── pages/
│ └── index.astro
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
Any static assets, like images, can be placed in the `public/` directory.
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :----------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
## 👀 Want to learn more?
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).

14
frontend/astro.config.mjs Normal file
View File

@@ -0,0 +1,14 @@
// @ts-check
import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [
svelte(),
tailwind({
applyBaseStyles: false
})
]
});

6868
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "termi-astro",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/svelte": "^8.0.3",
"@astrojs/tailwind": "^6.0.2",
"@tailwindcss/typography": "^0.5.19",
"astro": "^6.0.8",
"autoprefixer": "^10.4.27",
"lucide-astro": "^0.556.0",
"postcss": "^8.5.8",
"svelte": "^5.55.0",
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@astrojs/check": "^0.9.8",
"typescript": "^6.0.2"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

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>

View File

@@ -0,0 +1,220 @@
---
import '../styles/global.css';
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';
interface Props {
title?: string;
description?: string;
}
const props = Astro.props;
let siteSettings = DEFAULT_SITE_SETTINGS;
try {
siteSettings = await api.getSiteSettings();
} catch (error) {
console.error('Failed to load site settings:', error);
}
const title = props.title || siteSettings.siteTitle;
const description = props.description || siteSettings.siteDescription;
---
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<style is:inline>
:root {
--primary: #2563eb;
--primary-rgb: 37 99 235;
--primary-light: rgba(37 99 235 / 0.14);
--primary-dark: #1d4ed8;
--secondary: #f97316;
--secondary-rgb: 249 115 22;
--secondary-light: rgba(249 115 22 / 0.14);
--bg: #eef3f8;
--bg-rgb: 238 243 248;
--bg-secondary: #e2e8f0;
--bg-tertiary: #cbd5e1;
--terminal-bg: #f8fbff;
--text: #0f172a;
--text-rgb: 15 23 42;
--text-secondary: #475569;
--text-tertiary: #7c8aa0;
--terminal-text: #0f172a;
--title-color: #0f172a;
--button-text: #0f172a;
--border-color: #d6e0ea;
--border-color-rgb: 214 224 234;
--terminal-border: #d6e0ea;
--tag-bg: #edf3f8;
--tag-text: #0f172a;
--header-bg: rgba(244 248 252 / 0.92);
--code-bg: #eef3f8;
--success: #10b981;
--success-rgb: 16 185 129;
--success-light: #d1fae5;
--success-dark: #065f46;
--warning: #f59e0b;
--warning-rgb: 245 158 11;
--warning-light: #fef3c7;
--warning-dark: #92400e;
--danger: #ef4444;
--danger-rgb: 239 68 68;
--danger-light: #fee2e2;
--danger-dark: #991b1b;
--gray-light: #f3f4f6;
--gray-dark: #374151;
--btn-close: #ff5f56;
--btn-minimize: #ffbd2e;
--btn-expand: #27c93f;
}
html.dark {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255 255 255 / 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255 255 255 / 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: rgba(22 27 34 / 0.9);
--code-bg: #161b22;
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255 255 255 / 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255 255 255 / 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: rgba(22 27 34 / 0.9);
--code-bg: #161b22;
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
}
body {
background-color: var(--bg);
color: var(--text);
}
</style>
<script is:inline>
(function() {
const theme = localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (!theme && systemDark)) {
document.documentElement.classList.add('dark');
} else if (theme === 'light') {
document.documentElement.classList.add('light');
}
})();
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body class="min-h-screen bg-[var(--bg)] text-[var(--text)] font-sans antialiased">
<div class="relative min-h-screen flex flex-col">
<div class="fixed inset-0 -z-10 bg-[var(--bg)]"></div>
<div
class="fixed inset-0 -z-10 opacity-70"
style="background:
radial-gradient(circle at top left, rgba(var(--primary-rgb), 0.06), transparent 30%),
radial-gradient(circle at top right, rgba(var(--secondary-rgb), 0.05), transparent 26%),
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 34%, transparent), transparent 48%);"
></div>
<div
class="fixed inset-0 -z-10 opacity-30"
style="background-image:
linear-gradient(rgba(var(--primary-rgb), 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(var(--primary-rgb), 0.03) 1px, transparent 1px);
background-size: 100% 18px, 18px 100%;"
></div>
<Header siteSettings={siteSettings} />
<main class="flex-1 w-full">
<slot />
</main>
<Footer siteSettings={siteSettings} />
<BackToTop client:load />
</div>
</body>
</html>
<style>
:global(body) {
font-family: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
:global(code, pre) {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
}
</style>

View File

@@ -0,0 +1,404 @@
import type {
Category as UiCategory,
FriendLink as UiFriendLink,
Post as UiPost,
SiteSettings,
Tag as UiTag,
} from '../types';
export const API_BASE_URL = 'http://localhost:5150/api';
export interface ApiPost {
id: number;
title: string;
slug: string;
description: string;
content: string;
category: string;
tags: string[];
post_type: 'article' | 'tweet';
image: string | null;
pinned: boolean;
created_at: string;
updated_at: string;
}
export interface Comment {
id: number;
post_id: string | null;
post_slug: string | null;
author: string | null;
email: string | null;
avatar: string | null;
content: string | null;
reply_to: string | null;
approved: boolean | null;
created_at: string;
updated_at: string;
}
export interface CreateCommentInput {
postSlug: string;
nickname: string;
email?: string;
content: string;
replyTo?: string | null;
}
export interface ApiTag {
id: number;
name: string;
slug: string;
created_at: string;
updated_at: string;
}
export interface ApiCategory {
id: number;
name: string;
slug: string;
count: number;
}
export interface ApiFriendLink {
id: number;
site_name: string;
site_url: string;
avatar_url: string | null;
description: string | null;
category: string | null;
status: 'pending' | 'approved' | 'rejected';
created_at: string;
updated_at: string;
}
export interface CreateFriendLinkInput {
siteName: string;
siteUrl: string;
avatarUrl?: string;
description: string;
category?: string;
}
export interface ApiSiteSettings {
id: number;
site_name: string | null;
site_short_name: string | null;
site_url: string | null;
site_title: string | null;
site_description: string | null;
hero_title: string | null;
hero_subtitle: string | null;
owner_name: string | null;
owner_title: string | null;
owner_bio: string | null;
owner_avatar_url: string | null;
social_github: string | null;
social_twitter: string | null;
social_email: string | null;
location: string | null;
tech_stack: string[] | null;
}
export interface ApiSearchResult {
id: number;
title: string | null;
slug: string;
description: string | null;
content: string | null;
category: string | null;
tags: string[] | null;
post_type: 'article' | 'tweet' | null;
image: string | null;
pinned: boolean | null;
created_at: string;
updated_at: string;
rank: number;
}
export interface Review {
id: number;
title: string;
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie';
rating: number;
review_date: string;
status: 'completed' | 'in-progress' | 'dropped';
description: string;
tags: string;
cover: string;
created_at: string;
updated_at: string;
}
export type AppFriendLink = UiFriendLink & {
status: ApiFriendLink['status'];
};
export const DEFAULT_SITE_SETTINGS: SiteSettings = {
id: '1',
siteName: 'InitCool',
siteShortName: 'Termi',
siteUrl: 'https://termi.dev',
siteTitle: 'InitCool - 终端风格的内容平台',
siteDescription: '一个基于终端美学的个人内容站,记录代码、设计和生活。',
heroTitle: '欢迎来到我的极客终端博客',
heroSubtitle: '这里记录技术、代码和生活点滴',
ownerName: 'InitCool',
ownerTitle: '前端开发者 / 技术博主',
ownerBio: '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。',
location: 'Hong Kong',
social: {
github: 'https://github.com',
twitter: 'https://twitter.com',
email: 'mailto:hello@termi.dev',
},
techStack: ['Astro', 'Svelte', 'Tailwind CSS', 'TypeScript'],
};
const formatPostDate = (dateString: string) => dateString.slice(0, 10);
const estimateReadTime = (content: string | null | undefined) => {
const text = content?.trim() || '';
const minutes = Math.max(1, Math.ceil(text.length / 300));
return `${minutes} 分钟`;
};
const normalizePost = (post: ApiPost): UiPost => ({
id: String(post.id),
slug: post.slug,
title: post.title,
description: post.description,
content: post.content,
date: formatPostDate(post.created_at),
readTime: estimateReadTime(post.content || post.description),
type: post.post_type,
tags: post.tags ?? [],
category: post.category,
image: post.image ?? undefined,
pinned: post.pinned,
});
const normalizeTag = (tag: ApiTag): UiTag => ({
id: String(tag.id),
name: tag.name,
slug: tag.slug,
});
const normalizeCategory = (category: ApiCategory): UiCategory => ({
id: String(category.id),
name: category.name,
slug: category.slug,
count: category.count,
});
const normalizeAvatarUrl = (value: string | null | undefined) => {
if (!value) {
return undefined;
}
try {
const host = new URL(value).hostname.toLowerCase();
const isReservedExampleHost =
host === 'example.com' ||
host === 'example.org' ||
host === 'example.net' ||
host.endsWith('.example.com') ||
host.endsWith('.example.org') ||
host.endsWith('.example.net');
return isReservedExampleHost ? undefined : value;
} catch {
return undefined;
}
};
const normalizeTagToken = (value: string) => value.trim().toLowerCase();
const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
id: String(friendLink.id),
name: friendLink.site_name,
url: friendLink.site_url,
avatar: normalizeAvatarUrl(friendLink.avatar_url),
description: friendLink.description ?? undefined,
category: friendLink.category ?? undefined,
status: friendLink.status,
});
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => ({
id: String(settings.id),
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle,
ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio,
ownerAvatarUrl: settings.owner_avatar_url ?? undefined,
location: settings.location || DEFAULT_SITE_SETTINGS.location,
social: {
github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github,
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
});
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private async fetch<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options?.headers,
},
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
async getRawPosts(): Promise<ApiPost[]> {
return this.fetch<ApiPost[]>('/posts');
}
async getPosts(): Promise<UiPost[]> {
const posts = await this.getRawPosts();
return posts.map(normalizePost);
}
async getPost(id: number): Promise<UiPost> {
const post = await this.fetch<ApiPost>(`/posts/${id}`);
return normalizePost(post);
}
async getPostBySlug(slug: string): Promise<UiPost | null> {
const posts = await this.getPosts();
return posts.find(post => post.slug === slug) || null;
}
async getComments(postSlug: string, options?: { approved?: boolean }): Promise<Comment[]> {
const params = new URLSearchParams({ post_slug: postSlug });
if (options?.approved !== undefined) {
params.set('approved', String(options.approved));
}
return this.fetch<Comment[]>(`/comments?${params.toString()}`);
}
async createComment(comment: CreateCommentInput): Promise<Comment> {
return this.fetch<Comment>('/comments', {
method: 'POST',
body: JSON.stringify({
postSlug: comment.postSlug,
nickname: comment.nickname,
email: comment.email,
content: comment.content,
replyTo: comment.replyTo,
}),
});
}
async getReviews(): Promise<Review[]> {
return this.fetch<Review[]>('/reviews');
}
async getReview(id: number): Promise<Review> {
return this.fetch<Review>(`/reviews/${id}`);
}
async getRawFriendLinks(): Promise<ApiFriendLink[]> {
return this.fetch<ApiFriendLink[]>('/friend_links');
}
async getFriendLinks(): Promise<AppFriendLink[]> {
const friendLinks = await this.getRawFriendLinks();
return friendLinks.map(normalizeFriendLink);
}
async createFriendLink(friendLink: CreateFriendLinkInput): Promise<ApiFriendLink> {
return this.fetch<ApiFriendLink>('/friend_links', {
method: 'POST',
body: JSON.stringify(friendLink),
});
}
async getRawTags(): Promise<ApiTag[]> {
return this.fetch<ApiTag[]>('/tags');
}
async getTags(): Promise<UiTag[]> {
const tags = await this.getRawTags();
return tags.map(normalizeTag);
}
async getRawSiteSettings(): Promise<ApiSiteSettings> {
return this.fetch<ApiSiteSettings>('/site_settings');
}
async getSiteSettings(): Promise<SiteSettings> {
const settings = await this.getRawSiteSettings();
return normalizeSiteSettings(settings);
}
async getCategories(): Promise<UiCategory[]> {
const categories = await this.fetch<ApiCategory[]>('/categories');
return categories.map(normalizeCategory);
}
async getPostsByCategory(category: string): Promise<UiPost[]> {
const posts = await this.getPosts();
return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase());
}
async getPostsByTag(tag: string): Promise<UiPost[]> {
const posts = await this.getPosts();
const normalizedTag = normalizeTagToken(tag);
return posts.filter(post =>
post.tags?.some(item => normalizeTagToken(item) === normalizedTag)
);
}
async searchPosts(query: string, limit = 20): Promise<UiPost[]> {
const params = new URLSearchParams({
q: query,
limit: String(limit),
});
const results = await this.fetch<ApiSearchResult[]>(`/search?${params.toString()}`);
return results.map(result =>
normalizePost({
id: result.id,
title: result.title || 'Untitled',
slug: result.slug,
description: result.description || '',
content: result.content || '',
category: result.category || '',
tags: result.tags ?? [],
post_type: result.post_type || 'article',
image: result.image,
pinned: result.pinned ?? false,
created_at: result.created_at,
updated_at: result.updated_at,
})
);
}
}
export const api = new ApiClient(API_BASE_URL);
export const apiClient = api;

View File

@@ -0,0 +1,167 @@
export interface TerminalConfig {
defaultCategory: string;
welcomeMessage: string;
prompt: {
prefix: string;
separator: string;
path: string;
suffix: string;
mobile: string;
};
asciiArt: string;
title: string;
welcome: {
title: string;
subtitle: string;
};
navLinks: Array<{
icon: string;
text: string;
href: string;
}>;
categories: {
[key: string]: {
title: string;
description: string;
items: Array<{
command: string;
description: string;
shortDesc?: string;
url?: string;
}>;
};
};
postTypes: {
article: { color: string; label: string };
tweet: { color: string; label: string };
};
pinnedPost?: {
title: string;
description: string;
date: string;
readTime: string;
type: 'article' | 'tweet';
tags: string[];
link: string;
};
socialLinks: {
github: string;
twitter: string;
email: string;
};
tools: Array<{
icon: string;
href: string;
title: string;
}>;
search?: {
placeholders: {
default: string;
small: string;
medium: string;
};
promptText: string;
emptyResultText: string;
};
terminal?: {
defaultWindowTitle: string;
controls: {
colors: {
close: string;
minimize: string;
expand: string;
};
};
animation?: {
glowDuration: string;
};
};
branding?: {
name: string;
shortName?: string;
};
}
export const terminalConfig: TerminalConfig = {
defaultCategory: 'blog',
welcomeMessage: '欢迎来到我的博客!',
prompt: {
prefix: 'user@blog',
separator: ':',
path: '~/',
suffix: '$',
mobile: '~$'
},
asciiArt: `
I N N I TTTTT CCCC OOO OOO L
I NN N I T C O O O O L
I N N N I T C O O O O L
I N NN I T C O O O O L
I N N I T CCCC OOO OOO LLLLL`,
title: '~/blog',
welcome: {
title: '欢迎来到我的极客终端博客',
subtitle: '这里记录技术、代码和生活点滴'
},
navLinks: [
{ icon: 'fa-file-code', text: '文章', href: '/articles' },
{ icon: 'fa-folder', text: '分类', href: '/categories' },
{ icon: 'fa-tags', text: '标签', href: '/tags' },
{ icon: 'fa-stream', text: '时间轴', href: '/timeline' },
{ icon: 'fa-star', text: '评价', href: '/reviews' },
{ icon: 'fa-link', text: '友链', href: '/friends' },
{ icon: 'fa-user-secret', text: '关于', href: '/about' }
],
categories: {
blog: {
title: '博客',
description: '我的个人博客文章',
items: [
{
command: 'help',
description: '显示帮助信息',
shortDesc: '显示帮助信息'
}
]
}
},
postTypes: {
article: { color: '#00ff9d', label: '博客文章' },
tweet: { color: '#00b8ff', label: '推文' }
},
socialLinks: {
github: '',
twitter: '',
email: ''
},
tools: [
{ icon: 'fa-sitemap', href: '/sitemap.xml', title: '站点地图' },
{ icon: 'fa-rss', href: '/rss.xml', title: 'RSS订阅' }
],
search: {
placeholders: {
default: "'关键词' articles/*.md",
small: "搜索...",
medium: "搜索文章..."
},
promptText: "grep -i",
emptyResultText: "输入关键词搜索文章"
},
terminal: {
defaultWindowTitle: 'user@terminal: ~/blog',
controls: {
colors: {
close: '#ff5f56',
minimize: '#ffbd2e',
expand: '#27c93f'
}
},
animation: {
glowDuration: '4s'
}
},
branding: {
name: 'InitCool',
shortName: 'Termi'
}
};

View File

@@ -0,0 +1,435 @@
/* 现代化主题系统 - 使用 CSS 变量 + 媒体查询 */
:root {
/* 声明支持的颜色方案 */
color-scheme: light dark;
/* 全局变量 */
--transition-duration: 0.3s;
/* 亮色模式默认 */
--primary: #4285f4;
--primary-rgb: 66 133 244;
--primary-light: #4285f433;
--primary-dark: #3367d6;
--secondary: #ea580c;
--secondary-rgb: 234 88 12;
--secondary-light: #ea580c33;
--bg: #f3f4f6;
--bg-rgb: 243 244 246;
--bg-secondary: #e5e7eb;
--bg-tertiary: #d1d5db;
--terminal-bg: #ffffff;
--text: #1a1a1a;
--text-rgb: 26 26 26;
--text-secondary: #9ca3af;
--text-tertiary: #cbd5e1;
--terminal-text: #1a1a1a;
--title-color: #1a1a1a;
--button-text: #1a1a1a;
--border-color: #e5e7eb;
--border-color-rgb: 229 231 235;
--terminal-border: #e5e7eb;
--tag-bg: #f3f4f6;
--tag-text: #1a1a1a;
--header-bg: #f9fafb;
--code-bg: #f3f4f6;
/* 终端窗口控制按钮 */
--btn-close: #ff5f56;
--btn-minimize: #ffbd2e;
--btn-expand: #27c93f;
/* 状态颜色 */
--success: #10b981;
--success-rgb: 16 185 129;
--success-light: #d1fae5;
--success-dark: #065f46;
--warning: #f59e0b;
--warning-rgb: 245 158 11;
--warning-light: #fef3c7;
--warning-dark: #92400e;
--danger: #ef4444;
--danger-rgb: 239 68 68;
--danger-light: #fee2e2;
--danger-dark: #991b1b;
--gray-light: #f3f4f6;
--gray-dark: #374151;
/* 全局样式变量 */
--border-radius: 0.5rem;
--box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
/* 暗色模式 */
@media (prefers-color-scheme: dark) {
:root {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255, 255, 255, 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255, 255, 255, 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: #161b22;
--code-bg: #161b22;
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
}
/* 手动暗色模式覆盖 - 使用 html.dark */
html.dark {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255, 255, 255, 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255, 255, 255, 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: #161b22;
--code-bg: #161b22;
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
/* 优化的平滑过渡 - 只应用到需要的元素 */
body,
button,
input,
select,
textarea,
a {
transition: background-color var(--transition-duration) ease,
color var(--transition-duration) ease,
border-color var(--transition-duration) ease;
}
/* 主题切换按钮动画 */
.theme-toggle {
transition: all 0.3s ease;
}
.theme-toggle i {
transition: transform 0.3s ease;
}
.theme-toggle:hover i {
transform: rotate(30deg);
}
.terminal-input {
width: 100%;
background-color: color-mix(in oklab, var(--terminal-bg) 84%, var(--bg-secondary)) !important;
background-image:
linear-gradient(to bottom, rgba(var(--primary-rgb), 0.04), rgba(var(--primary-rgb), 0.0));
border: 1px solid rgba(var(--primary-rgb), 0.42) !important;
outline: 1px solid rgba(var(--primary-rgb), 0.22);
outline-offset: -1px;
color: var(--text) !important;
font-family: var(--font-mono);
padding: 0.55rem 0.7rem;
border-radius: var(--border-radius);
font-size: 0.9rem;
line-height: 1.25;
letter-spacing: 0.01em;
caret-color: var(--primary);
appearance: none;
display: block;
box-shadow:
inset 0 0 0 1px rgba(var(--primary-rgb), 0.16),
inset 0 10px 20px rgba(0, 0, 0, 0.10),
inset 0 0 26px rgba(var(--primary-rgb), 0.08),
0 0 0 1px rgba(0, 0, 0, 0.14);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
@media (prefers-color-scheme: dark) {
.terminal-input {
background-color: color-mix(in oklab, var(--bg-tertiary) 88%, var(--terminal-bg)) !important;
background-image:
linear-gradient(to bottom, rgba(var(--primary-rgb), 0.06), rgba(var(--primary-rgb), 0.0)),
repeating-linear-gradient(
to bottom,
rgba(var(--primary-rgb), 0.06) 0px,
rgba(var(--primary-rgb), 0.06) 1px,
transparent 1px,
transparent 6px
);
border: 1px solid rgba(var(--primary-rgb), 0.48) !important;
outline: 1px solid rgba(var(--primary-rgb), 0.22);
box-shadow:
inset 0 0 0 1px rgba(var(--primary-rgb), 0.16),
inset 0 10px 20px rgba(0, 0, 0, 0.32),
inset 0 0 26px rgba(var(--primary-rgb), 0.12),
0 0 0 1px rgba(0, 0, 0, 0.26);
}
}
html.dark .terminal-input {
background-color: color-mix(in oklab, var(--bg-tertiary) 88%, var(--terminal-bg)) !important;
background-image:
linear-gradient(to bottom, rgba(var(--primary-rgb), 0.06), rgba(var(--primary-rgb), 0.0)),
repeating-linear-gradient(
to bottom,
rgba(var(--primary-rgb), 0.06) 0px,
rgba(var(--primary-rgb), 0.06) 1px,
transparent 1px,
transparent 6px
);
border: 1px solid rgba(var(--primary-rgb), 0.48) !important;
outline: 1px solid rgba(var(--primary-rgb), 0.22);
box-shadow:
inset 0 0 0 1px rgba(var(--primary-rgb), 0.16),
inset 0 10px 20px rgba(0, 0, 0, 0.32),
inset 0 0 26px rgba(var(--primary-rgb), 0.12),
0 0 0 1px rgba(0, 0, 0, 0.26);
}
.terminal-input::placeholder {
color: rgba(0, 0, 0, 0.45);
letter-spacing: 0.02em;
}
@media (prefers-color-scheme: dark) {
.terminal-input::placeholder {
color: rgba(255, 255, 255, 0.45);
}
}
html.dark .terminal-input::placeholder {
color: rgba(255, 255, 255, 0.45);
}
.terminal-input:focus {
outline: none;
border-color: var(--primary) !important;
box-shadow:
inset 0 0 0 1px rgba(var(--primary-rgb), 0.22),
inset 0 0 26px rgba(var(--primary-rgb), 0.14),
0 0 0 2px rgba(var(--primary-rgb), 0.22),
0 0 28px rgba(var(--primary-rgb), 0.24);
}
.terminal-input.textarea {
resize: vertical;
min-height: 4.5rem;
}
/* Terminal Window Glow Effects */
.terminal-window {
background-color: var(--terminal-bg);
border-radius: 8px !important;
border: 1px solid var(--primary) !important;
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.4),
0 0 20px rgba(var(--primary-rgb), 0.2),
0 10px 30px rgba(var(--primary-rgb), 0.2) !important;
overflow: hidden;
position: relative;
animation: terminal-glow 4s ease-in-out infinite alternate !important;
}
@keyframes terminal-glow {
0% {
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.4),
0 0 20px rgba(var(--primary-rgb), 0.2),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
100% {
box-shadow:
0 0 12px rgba(var(--primary-rgb), 0.5),
0 0 25px rgba(var(--primary-rgb), 0.3),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
}
/* Dark mode glow adjustments */
@media (prefers-color-scheme: dark) {
.terminal-window {
animation: terminal-glow-dark 4s ease-in-out infinite alternate !important;
}
@keyframes terminal-glow-dark {
0% {
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.3),
0 0 20px rgba(var(--primary-rgb), 0.15),
0 10px 40px rgba(var(--primary-rgb), 0.1);
border-color: rgba(var(--primary-rgb), 0.5) !important;
}
100% {
box-shadow:
0 0 15px rgba(var(--primary-rgb), 0.5),
0 0 30px rgba(var(--primary-rgb), 0.25),
0 10px 50px rgba(var(--primary-rgb), 0.15);
border-color: rgba(var(--primary-rgb), 0.8) !important;
}
}
}
html.dark .terminal-window {
animation: terminal-glow-dark 4s ease-in-out infinite alternate !important;
}
/* Terminal Header */
.terminal-header {
border-bottom: 1px solid var(--primary) !important;
box-shadow: 0 1px 5px rgba(var(--primary-rgb), 0.2) !important;
}
/* Glow Text Effect */
.glow-text {
text-shadow: 0 0 10px rgba(var(--primary-rgb), 0.5);
}
/* Post Card Hover Effects */
.post-card {
position: relative;
transition: all 0.3s ease;
}
.post-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
border-radius: 4px 0 0 4px;
opacity: 0;
transition: opacity 0.3s ease;
background-color: var(--post-border-color, var(--primary));
}
.post-card:hover {
transform: translateX(8px);
}
.post-card:hover::before {
opacity: 1;
}
/* Cursor Blink Animation */
.cursor {
display: inline-block;
width: 10px;
height: 18px;
background-color: var(--primary);
animation: blink 1s infinite;
vertical-align: middle;
margin-left: 2px;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ASCII Art Styling */
.ascii-art {
font-family: 'Courier New', monospace;
font-size: 0.85rem;
line-height: 1.2;
color: var(--primary);
white-space: pre;
margin: 0;
padding: 0;
text-align: left;
letter-spacing: 0;
overflow-x: auto;
}
@media (max-width: 640px) {
.ascii-art {
font-size: 0.625rem;
}
}
/* Link Hover Effects */
a {
transition: all 0.3s ease;
}
/* Button Hover Glow */
button:hover,
a:hover {
text-shadow: 0 0 8px rgba(var(--primary-rgb), 0.3);
}

View File

@@ -0,0 +1,88 @@
export interface Post {
id: string;
slug: string;
title: string;
description: string;
content?: string;
date: string;
readTime: string;
type: 'article' | 'tweet';
tags: string[];
category: string;
image?: string;
images?: string[];
code?: string;
language?: string;
pinned?: boolean;
}
export interface Category {
id: string;
name: string;
slug: string;
description?: string;
icon?: string;
count?: number;
}
export interface Tag {
id: string;
name: string;
slug: string;
count?: number;
}
export interface FriendLink {
id: string;
name: string;
url: string;
avatar?: string;
description?: string;
category?: string;
}
export interface SiteSettings {
id: string;
siteName: string;
siteShortName: string;
siteUrl: string;
siteTitle: string;
siteDescription: string;
heroTitle: string;
heroSubtitle: string;
ownerName: string;
ownerTitle: string;
ownerBio: string;
ownerAvatarUrl?: string;
location?: string;
social: {
github?: string;
twitter?: string;
email?: string;
};
techStack: string[];
}
export interface SiteConfig {
name: string;
description: string;
author: string;
url: string;
social: {
github?: string;
twitter?: string;
email?: string;
};
}
export interface SystemStat {
label: string;
value: string;
icon?: string;
}
export interface TechStackItem {
name: string;
icon?: string;
level?: string;
}

View File

@@ -0,0 +1,194 @@
import type { Post, Category, Tag, FriendLink } from '../types';
// Mock data for static site generation
export const mockPosts: Post[] = [
{
id: '1',
slug: 'welcome-to-termi',
title: '欢迎来到 Termi 终端博客',
description: '这是一个基于终端风格的现代博客平台,结合了极客美学与极致性能。',
date: '2024-03-20',
readTime: '3 分钟',
type: 'article',
tags: ['astro', 'svelte', 'tailwind'],
category: '技术',
pinned: true,
image: 'https://picsum.photos/1200/600?random=1'
},
{
id: '2',
slug: 'astro-ssg-guide',
title: 'Astro 静态站点生成指南',
description: '学习如何使用 Astro 构建高性能的静态网站,掌握群岛架构的核心概念。',
date: '2024-03-18',
readTime: '5 分钟',
type: 'article',
tags: ['astro', 'ssg', 'performance'],
category: '前端'
},
{
id: '3',
slug: 'tailwind-v4-features',
title: 'Tailwind CSS v4 新特性解析',
description: '探索 Tailwind CSS v4 带来的全新特性,包括改进的性能和更简洁的配置。',
date: '2024-03-15',
readTime: '4 分钟',
type: 'article',
tags: ['tailwind', 'css', 'design'],
category: '前端'
},
{
id: '4',
slug: 'daily-thought-1',
title: '关于代码与咖啡的思考',
description: '写代码就像冲咖啡,需要耐心和恰到好处的温度。今天尝试了几款新豆子,每一杯都有不同的风味。',
date: '2024-03-14',
readTime: '1 分钟',
type: 'tweet',
tags: ['life', 'coding'],
category: '随笔',
images: [
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?w=400&h=400&fit=crop',
'https://images.unsplash.com/photo-1509042239860-f550ce710b93?w=400&h=400&fit=crop',
'https://images.unsplash.com/photo-1514432324607-a09d9b4aefdd?w=400&h=400&fit=crop'
]
},
{
id: '5',
slug: 'svelte-5-runes',
title: 'Svelte 5 Runes 完全指南',
description: '深入了解 Svelte 5 的 Runes 系统,掌握下一代响应式编程范式。',
date: '2024-03-10',
readTime: '8 分钟',
type: 'article',
tags: ['svelte', 'javascript', 'frontend'],
category: '前端'
}
];
export const mockCategories: Category[] = [
{ id: '1', name: '技术', slug: 'tech', icon: 'fa-code', count: 3 },
{ id: '2', name: '前端', slug: 'frontend', icon: 'fa-laptop-code', count: 3 },
{ id: '3', name: '随笔', slug: 'essay', icon: 'fa-pen', count: 1 },
{ id: '4', name: '生活', slug: 'life', icon: 'fa-coffee', count: 1 }
];
export const mockTags: Tag[] = [
{ id: '1', name: 'astro', slug: 'astro', count: 1 },
{ id: '2', name: 'svelte', slug: 'svelte', count: 2 },
{ id: '3', name: 'tailwind', slug: 'tailwind', count: 2 },
{ id: '4', name: 'frontend', slug: 'frontend', count: 2 },
{ id: '5', name: 'ssg', slug: 'ssg', count: 1 },
{ id: '6', name: 'css', slug: 'css', count: 1 },
{ id: '7', name: 'javascript', slug: 'javascript', count: 1 },
{ id: '8', name: 'life', slug: 'life', count: 1 },
{ id: '9', name: 'coding', slug: 'coding', count: 1 }
];
export const mockFriendLinks: FriendLink[] = [
{
id: '1',
name: 'Astro 官网',
url: 'https://astro.build',
avatar: 'https://astro.build/favicon.svg',
description: '极速内容驱动的网站框架',
category: '技术博客'
},
{
id: '2',
name: 'Svelte 官网',
url: 'https://svelte.dev',
avatar: 'https://svelte.dev/favicon.png',
description: '控制论增强的 Web 应用',
category: '技术博客'
},
{
id: '3',
name: 'Tailwind CSS',
url: 'https://tailwindcss.com',
avatar: 'https://tailwindcss.com/favicons/favicon-32x32.png',
description: '实用优先的 CSS 框架',
category: '技术博客'
}
];
export const mockSiteConfig = {
name: 'Termi',
description: '终端风格的内容平台',
author: 'InitCool',
url: 'https://termi.dev',
social: {
github: 'https://github.com',
twitter: 'https://twitter.com',
email: 'mailto:hello@termi.dev'
}
};
export const mockSystemStats = [
{ label: 'Last Update', value: '2024-03-20' },
{ label: 'Posts', value: '12' },
{ label: 'Visitors', value: '1.2k' }
];
export const mockTechStack = [
{ name: 'Astro' },
{ name: 'Svelte' },
{ name: 'Tailwind CSS' },
{ name: 'TypeScript' },
{ name: 'Vercel' }
];
export const mockHomeAboutIntro = '一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。';
// Helper functions
export function getPinnedPost(): Post | null {
return mockPosts.find(p => p.pinned) || null;
}
export function getRecentPosts(limit: number = 5): Post[] {
return mockPosts.slice(0, limit);
}
export function getAllPosts(): Post[] {
return mockPosts;
}
export function getPostBySlug(slug: string): Post | undefined {
return mockPosts.find(p => p.slug === slug);
}
export function getPostsByTag(tag: string): Post[] {
return mockPosts.filter(p => p.tags.includes(tag));
}
export function getPostsByCategory(category: string): Post[] {
return mockPosts.filter(p => p.category === category);
}
export function getAllCategories(): Category[] {
return mockCategories;
}
export function getAllTags(): Tag[] {
return mockTags;
}
export function getAllFriendLinks(): FriendLink[] {
return mockFriendLinks;
}
export function getSiteConfig() {
return mockSiteConfig;
}
export function getSystemStats() {
return mockSystemStats;
}
export function getTechStack() {
return mockTechStack;
}
export function getHomeAboutIntro() {
return mockHomeAboutIntro;
}

View File

@@ -0,0 +1,94 @@
/**
* Format a date string to a more readable format
*/
export function formatDate(dateString: string): string {
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
/**
* Truncate text to a specified length with ellipsis
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
}
/**
* Normalize FontAwesome icon class
*/
export function normalizeFaIcon(icon: unknown): string {
const raw = typeof icon === 'string' ? icon.trim() : '';
if (!raw) return 'fa-folder';
if (raw.includes('fa-')) {
const parts = raw.split(/\s+/);
for (let i = parts.length - 1; i >= 0; i -= 1) {
const t = parts[i];
if (t?.startsWith('fa-')) return t;
}
return 'fa-folder';
}
return 'fa-folder';
}
/**
* Resolve file reference (for images)
*/
export function resolveFileRef(ref: string): string {
if (ref.startsWith('http://') || ref.startsWith('https://') || ref.startsWith('/')) {
return ref;
}
return `/uploads/${ref}`;
}
/**
* Generate a unique ID
*/
export function generateId(): string {
return Math.random().toString(36).substring(2, 9);
}
/**
* Debounce function
*/
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
/**
* Filter posts by type and tag
*/
export function filterPosts(
posts: Array<{
type: string;
tags: string[];
}>,
postType: string,
tag: string
): typeof posts {
return posts.filter(post => {
if (postType !== 'all' && post.type !== postType) return false;
if (tag && !post.tags.includes(tag)) return false;
return true;
});
}
/**
* Get color for post type
*/
export function getPostTypeColor(type: string): string {
return type === 'article' ? 'var(--primary)' : 'var(--secondary)';
}

View File

@@ -0,0 +1,156 @@
---
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';
const fullPrompt = `${terminalConfig.prompt.prefix}${terminalConfig.prompt.separator}${terminalConfig.prompt.path}${terminalConfig.prompt.suffix}`;
let popularPosts: Awaited<ReturnType<typeof api.getPosts>> = [];
try {
popularPosts = (await api.getPosts()).slice(0, 4);
} catch (error) {
console.error('Failed to load fallback posts:', error);
}
---
<Layout title="404 - 页面未找到" description="您访问的页面不存在">
<div class="max-w-4xl mx-auto px-4 py-12">
<TerminalWindow title={terminalConfig.title} class="w-full">
<div class="px-4 pb-2">
<div class="terminal-panel ml-4 mt-4 space-y-6">
<div class="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div class="space-y-4">
<span class="terminal-kicker">
<i class="fas fa-triangle-exclamation"></i>
missing route
</span>
<div class="space-y-3">
<pre class="ascii-art text-[var(--primary)]">EEEEE RRRR RRRR OOO RRRR
E R R R R O O R R
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>
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
当前请求没有命中任何内容节点。下面保留了终端化错误信息、可执行操作,以及可回退到的真实文章入口。
</p>
</div>
</div>
</div>
<div class="terminal-panel-muted min-w-[240px] space-y-3">
<div class="font-mono text-sm">
<span class="text-[var(--primary)]">{fullPrompt}</span>
<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>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<section class="terminal-panel-muted space-y-4">
<div class="terminal-section-title">
<span class="terminal-section-icon">
<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>
</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>
</button>
<a href="/" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-house"></i>
<span>home</span>
</a>
<a href="/articles" class="terminal-action-button">
<i class="fas fa-file-lines"></i>
<span>browse posts</span>
</a>
</div>
<div class="terminal-empty py-8">
<p class="text-sm leading-7 text-[var(--text-secondary)]">
也可以直接使用顶部的搜索输入框,在 `articles/*.md` 里重新 grep 一次相关关键字。
</p>
</div>
</section>
<section class="terminal-panel-muted space-y-4">
<div class="terminal-section-title">
<span class="terminal-section-icon">
<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>
</div>
</div>
<div class="space-y-3">
{popularPosts.length > 0 ? (
popularPosts.map(post => (
<a href={`/articles/${post.slug}`} class="terminal-console-list-item hover:border-[var(--primary)] transition-colors">
<div class="min-w-0 flex-1">
<p class="font-medium text-[var(--title-color)]">{post.title}</p>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{post.description}</p>
</div>
<i class="fas fa-arrow-right text-[var(--text-tertiary)]"></i>
</a>
))
) : (
<div class="terminal-empty py-8">
<p class="text-sm text-[var(--text-secondary)]">暂时无法读取文章列表。</p>
</div>
)}
</div>
</section>
</div>
</div>
</div>
</TerminalWindow>
</div>
<script is:inline>
document.getElementById('current-path').textContent = window.location.pathname;
document.getElementById('current-time').textContent = new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
</script>
</Layout>
<style>
.ascii-art {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.5rem;
line-height: 1;
margin: 0;
padding: 0;
white-space: pre;
}
@media (min-width: 640px) {
.ascii-art {
font-size: 0.75rem;
}
}
</style>

View File

@@ -0,0 +1,172 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
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';
let siteSettings = DEFAULT_SITE_SETTINGS;
let systemStats = [];
let techStack = [];
try {
const [settings, posts, tags, friendLinks] = await Promise.all([
api.getSiteSettings(),
api.getPosts(),
api.getTags(),
api.getFriendLinks(),
]);
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' },
];
} 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' },
];
}
const ownerInitial = siteSettings.ownerName.charAt(0) || 'T';
---
<BaseLayout title={`关于 - ${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">
<CommandPrompt command="whoami" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">identity profile</div>
<div class="terminal-section-title mt-4">
<span class="terminal-section-icon">
<i class="fas fa-user-circle"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">关于我</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
这里汇总站点主人、技术栈、系统状态和联系方式,现在整体语言会更接近首页与评价页。
</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>
<span class="terminal-stat-pill">
<i class="fas fa-layer-group text-[var(--primary)]"></i>
<span>{techStack.length} 项技术栈</span>
</span>
</div>
</div>
</div>
<div class="px-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div>
<CommandPrompt command="cat profile.txt" />
<div class="ml-4 mt-4">
<div class="terminal-panel p-6">
<div class="flex items-center gap-4 mb-4">
{siteSettings.ownerAvatarUrl ? (
<img
src={siteSettings.ownerAvatarUrl}
alt={siteSettings.ownerName}
class="w-16 h-16 rounded-full object-cover border border-[var(--border-color)]"
/>
) : (
<div class="w-16 h-16 rounded-full bg-[var(--primary)] flex items-center justify-center text-[var(--terminal-bg)] text-2xl font-bold shadow-lg shadow-[var(--primary)]/20">
{ownerInitial}
</div>
)}
<div>
<h2 class="text-xl font-bold text-[var(--title-color)]">{siteSettings.ownerName}</h2>
<p class="text-sm text-[var(--text-secondary)] mt-1">{siteSettings.ownerTitle}</p>
</div>
</div>
<p class="text-[var(--text-secondary)] leading-7">{siteSettings.ownerBio}</p>
</div>
</div>
<div class="mt-6">
<CommandPrompt command="cat tech_stack.txt" />
<div class="ml-4 mt-4">
<TechStackList items={techStack} />
</div>
</div>
</div>
<div>
<CommandPrompt command="cat system_info.txt" />
<div class="ml-4 mt-4">
<StatsList stats={systemStats} />
</div>
<div class="mt-6">
<CommandPrompt command="cat contact.txt" />
<div class="ml-4 mt-4">
<div class="flex flex-wrap gap-3">
{siteSettings.social.github && (
<InfoTile
href={siteSettings.social.github}
tone="neutral"
layout="grid"
target="_blank"
rel="noopener noreferrer"
>
<i class="fab fa-github text-[var(--text-secondary)]"></i>
<span class="text-sm">GitHub</span>
</InfoTile>
)}
{siteSettings.social.twitter && (
<InfoTile
href={siteSettings.social.twitter}
tone="neutral"
layout="grid"
target="_blank"
rel="noopener noreferrer"
>
<i class="fab fa-twitter text-[var(--text-secondary)]"></i>
<span class="text-sm">Twitter</span>
</InfoTile>
)}
{siteSettings.social.email && (
<InfoTile
href={siteSettings.social.email}
tone="neutral"
layout="grid"
>
<i class="fas fa-envelope text-[var(--text-secondary)]"></i>
<span class="text-sm">Email</span>
</InfoTile>
)}
<InfoTile
href={siteSettings.siteUrl}
tone="neutral"
layout="grid"
target="_blank"
rel="noopener noreferrer"
>
<i class="fas fa-globe text-[var(--text-secondary)]"></i>
<span class="text-sm">Website</span>
</InfoTile>
</div>
</div>
</div>
</div>
</div>
</div>
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -0,0 +1,331 @@
---
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';
let posts: Awaited<ReturnType<typeof api.getRawPosts>> = [];
let tags: Awaited<ReturnType<typeof api.getRawTags>> = [];
let friendLinks: Awaited<ReturnType<typeof api.getRawFriendLinks>> = [];
let reviews: Awaited<ReturnType<typeof api.getReviews>> = [];
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
let error: string | null = null;
try {
posts = await api.getRawPosts();
tags = await api.getRawTags();
friendLinks = await api.getRawFriendLinks();
reviews = await api.getReviews();
categories = await api.getCategories();
} catch (e) {
error = e instanceof Error ? e.message : 'API unavailable';
}
const pendingFriendLinks = friendLinks.filter(friendLink => friendLink.status === 'pending');
const pinnedPosts = posts.filter(post => post.pinned);
const recentPosts = [...posts].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6);
const activeCategories = [...categories]
.sort((a, b) => (b.count ?? 0) - (a.count ?? 0))
.slice(0, 6);
const tagSamples = tags.slice(0, 12);
const recentReviews = [...reviews].sort((a, b) => b.review_date.localeCompare(a.review_date)).slice(0, 4);
---
<BaseLayout title="控制台 - Termi">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title="~/admin/control-center" class="w-full">
<div class="px-4 pb-2">
<CommandPrompt command="sudo termi-admin --dashboard --mode=ops" />
<div class="terminal-panel ml-4 mt-4">
<div class="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-4">
<span class="terminal-kicker">
<i class="fas fa-sliders"></i>
operator console
</span>
<div class="space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)]">前台控制台</h1>
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
这个页面现在作为终端风格的运营工作台使用,用来查看内容库存、友链审核、标签分类和近期评价,
保持和整站统一的 geek / terminal 气质。
</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-file-lines text-[var(--primary)]"></i>
posts {posts.length}
</span>
<span class="terminal-stat-pill">
<i class="fas fa-tags text-[var(--primary)]"></i>
tags {tags.length}
</span>
<span class="terminal-stat-pill">
<i class="fas fa-link text-[var(--primary)]"></i>
links {friendLinks.length}
</span>
<span class="terminal-stat-pill">
<i class="fas fa-star text-[var(--primary)]"></i>
reviews {reviews.length}
</span>
</div>
</div>
</div>
</div>
{error && (
<div class="px-4 pb-2">
<div class="ml-4 mt-4 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));">
API 连接失败: {error}
</div>
</div>
)}
<div class="px-4 pb-2">
<div class="ml-4 mt-4 grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="terminal-panel-muted">
<div class="terminal-toolbar-label">posts</div>
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{posts.length}</div>
<p class="mt-2 text-sm text-[var(--text-secondary)]">内容总量</p>
</div>
<div class="terminal-panel-muted">
<div class="terminal-toolbar-label">pinned</div>
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{pinnedPosts.length}</div>
<p class="mt-2 text-sm text-[var(--text-secondary)]">置顶文章</p>
</div>
<div class="terminal-panel-muted">
<div class="terminal-toolbar-label">pending links</div>
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{pendingFriendLinks.length}</div>
<p class="mt-2 text-sm text-[var(--text-secondary)]">待处理友链</p>
</div>
<div class="terminal-panel-muted">
<div class="terminal-toolbar-label">categories</div>
<div class="mt-3 text-3xl font-bold text-[var(--title-color)]">{categories.length}</div>
<p class="mt-2 text-sm text-[var(--text-secondary)]">分类数量</p>
</div>
</div>
</div>
<div class="px-4 py-4">
<div class="ml-4 grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
<section class="terminal-panel space-y-5">
<div class="flex items-start justify-between gap-4">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-file-waveform"></i>
content queue
</span>
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-file-alt"></i>
</span>
<div>
<h2 class="text-xl font-semibold text-[var(--title-color)]">最近文章</h2>
<p class="text-sm text-[var(--text-secondary)]">按创建时间排列,方便快速检查最新导入和置顶状态。</p>
</div>
</div>
</div>
<a href="/articles" class="terminal-action-button">
<i class="fas fa-arrow-up-right-from-square"></i>
<span>open feed</span>
</a>
</div>
<div class="space-y-3">
{recentPosts.map(post => (
<div class="terminal-console-list-item">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<span class={`h-2.5 w-2.5 rounded-full ${post.post_type === 'article' ? 'bg-[var(--primary)]' : 'bg-[var(--secondary)]'}`}></span>
<span class="truncate font-medium text-[var(--title-color)]">{post.title}</span>
{post.pinned && (
<span class="terminal-chip px-2.5 py-1 text-xs">
<i class="fas fa-thumbtack text-[var(--primary)]"></i>
pinned
</span>
)}
</div>
<div class="flex flex-wrap gap-2 text-xs text-[var(--text-secondary)]">
<span>{post.slug}</span>
<span>/</span>
<span>{post.category}</span>
</div>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
<i class="far fa-calendar text-[var(--primary)]"></i>
{post.created_at.slice(0, 10)}
</span>
<a href={`/articles/${post.slug}`} class="terminal-action-button px-3 py-2 text-xs">
<i class="fas fa-eye"></i>
<span>view</span>
</a>
</div>
</div>
))}
</div>
</section>
<section class="space-y-6">
<div class="terminal-panel space-y-5">
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-link"></i>
</span>
<div>
<h2 class="text-xl font-semibold text-[var(--title-color)]">友链队列</h2>
<p class="text-sm text-[var(--text-secondary)]">突出待审核项目,让控制台页面更像真实的处理台。</p>
</div>
</div>
<div class="space-y-3">
{pendingFriendLinks.length > 0 ? (
pendingFriendLinks.map(link => (
<div class="terminal-console-list-item">
<div class="min-w-0 flex-1">
<p class="font-medium text-[var(--title-color)]">{link.site_name}</p>
<p class="mt-1 truncate text-sm text-[var(--text-secondary)]">{link.site_url}</p>
</div>
<span class="terminal-chip px-2.5 py-1 text-xs">
<i class="fas fa-hourglass-half text-[var(--warning)]"></i>
pending
</span>
</div>
))
) : (
<div class="terminal-empty py-8">
<p class="text-sm text-[var(--text-secondary)]">当前没有待审核友链。</p>
</div>
)}
</div>
</div>
<div class="terminal-panel space-y-5">
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-star-half-stroke"></i>
</span>
<div>
<h2 class="text-xl font-semibold text-[var(--title-color)]">近期评价</h2>
<p class="text-sm text-[var(--text-secondary)]">评价模块也纳入控制台视野,保持内容维度统一。</p>
</div>
</div>
<div class="space-y-3">
{recentReviews.map(review => (
<div class="terminal-console-list-item">
<div class="min-w-0 flex-1">
<p class="font-medium text-[var(--title-color)]">{review.title}</p>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{review.review_type} / {review.status}</p>
</div>
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
<i class="fas fa-star text-[var(--warning)]"></i>
{review.rating}/5
</span>
</div>
))}
</div>
</div>
</section>
</div>
</div>
<div class="px-4 pb-6">
<div class="ml-4 grid gap-6 lg:grid-cols-2">
<section class="terminal-panel space-y-5">
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-layer-group"></i>
</span>
<div>
<h2 class="text-xl font-semibold text-[var(--title-color)]">分类与标签</h2>
<p class="text-sm text-[var(--text-secondary)]">展示当前内容分布,方便观察导入后的归类情况。</p>
</div>
</div>
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
{activeCategories.map(category => (
<a href={`/categories?category=${encodeURIComponent(category.slug)}`} class="terminal-filter">
<i class="fas fa-folder-tree"></i>
<span>{category.name}</span>
<span class="text-[var(--text-tertiary)]">{category.count}</span>
</a>
))}
</div>
<div class="flex flex-wrap gap-2">
{tagSamples.map(tag => (
<a href={`/tags?tag=${encodeURIComponent(tag.slug || tag.name)}`} class="terminal-chip">
<i class="fas fa-hashtag text-[var(--primary)]"></i>
{tag.name}
</a>
))}
</div>
</div>
</section>
<section class="terminal-panel space-y-5">
<div class="terminal-section-title">
<span class="terminal-section-icon">
<i class="fas fa-terminal"></i>
</span>
<div>
<h2 class="text-xl font-semibold text-[var(--title-color)]">快捷入口</h2>
<p class="text-sm text-[var(--text-secondary)]">把常用页面作为控制台指令入口来呈现,弱化“默认后台模板感”。</p>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<a href="/" class="terminal-toolbar-module hover:border-[var(--primary)]">
<span class="terminal-section-icon h-10 w-10 rounded-xl">
<i class="fas fa-house"></i>
</span>
<div>
<div class="terminal-toolbar-label">front</div>
<div class="font-medium text-[var(--title-color)]">首页</div>
</div>
</a>
<a href="/friends" class="terminal-toolbar-module hover:border-[var(--primary)]">
<span class="terminal-section-icon h-10 w-10 rounded-xl">
<i class="fas fa-link"></i>
</span>
<div>
<div class="terminal-toolbar-label">links</div>
<div class="font-medium text-[var(--title-color)]">友链页</div>
</div>
</a>
<a href="/reviews" class="terminal-toolbar-module hover:border-[var(--primary)]">
<span class="terminal-section-icon h-10 w-10 rounded-xl">
<i class="fas fa-star"></i>
</span>
<div>
<div class="terminal-toolbar-label">reviews</div>
<div class="font-medium text-[var(--title-color)]">评价页</div>
</div>
</a>
<a href="http://localhost:5150/" class="terminal-toolbar-module hover:border-[var(--primary)]">
<span class="terminal-section-icon h-10 w-10 rounded-xl">
<i class="fas fa-server"></i>
</span>
<div>
<div class="terminal-toolbar-label">backend</div>
<div class="font-medium text-[var(--title-color)]">Loco 后台</div>
</div>
</a>
</div>
<div class="terminal-panel-muted">
<div class="terminal-toolbar-label">api endpoint</div>
<p class="mt-2 font-mono text-sm text-[var(--primary)]">http://localhost:5150/api</p>
</div>
</section>
</div>
</div>
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -0,0 +1,165 @@
---
import { createMarkdownProcessor } from '@astrojs/markdown-remark';
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import TableOfContents from '../../components/TableOfContents.astro';
import RelatedPosts from '../../components/RelatedPosts.astro';
import ReadingProgress from '../../components/ReadingProgress.astro';
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 { apiClient } from '../../lib/api/client';
import { resolveFileRef, getPostTypeColor } from '../../lib/utils';
export const prerender = false;
const { slug } = Astro.params;
let post = null;
try {
post = await apiClient.getPostBySlug(slug ?? '');
} catch (error) {
console.error('API Error:', error);
}
if (!post) {
return new Response(null, { status: 404 });
}
const typeColor = getPostTypeColor(post.type || 'article');
const contentText = post.content || post.description || '';
const wordCount = contentText.length;
const readTimeMinutes = Math.ceil(wordCount / 300);
const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, '');
const markdownProcessor = await createMarkdownProcessor();
const renderedContent = await markdownProcessor.render(articleMarkdown);
---
<BaseLayout title={`${post.title} - Termi`} description={post.description}>
<ReadingProgress />
<BackToTop />
<Lightbox />
<CodeCopyButton />
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<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">
<div class="px-4 pb-2">
<div class="terminal-panel ml-4 mt-4 space-y-5">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-4">
<a href="/articles" class="terminal-link-arrow">
<i class="fas fa-arrow-left"></i>
<span>返回文章索引</span>
</a>
<div class="flex flex-wrap items-center gap-2">
<span class="terminal-kicker">
<i class="fas fa-file-code"></i>
document session
</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'}
</span>
<span class="terminal-chip">
<i class="fas fa-folder-tree text-[var(--primary)]"></i>
{post.category}
</span>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="far fa-calendar text-[var(--primary)]"></i>
{post.date}
</span>
<span class="terminal-stat-pill">
<i class="far fa-clock text-[var(--primary)]"></i>
{readTimeMinutes} min
</span>
<span class="terminal-stat-pill">
<i class="fas fa-font text-[var(--primary)]"></i>
{wordCount} chars
</span>
</div>
</div>
<div class="space-y-3">
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)] sm:text-4xl">{post.title}</h1>
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{post.description}</p>
</div>
{post.tags?.length > 0 && (
<div class="flex flex-wrap gap-2">
{post.tags.map(tag => (
<a href={`/tags?tag=${encodeURIComponent(tag)}`} class="terminal-filter">
<i class="fas fa-hashtag"></i>
<span>{tag}</span>
</a>
))}
</div>
)}
</div>
</div>
<div class="px-4 pb-2">
<CommandPrompt command={`bat --style=plain ${post.slug}.md`} />
<div class="ml-4 mt-4 space-y-6">
{post.image && (
<div class="terminal-panel-muted overflow-hidden">
<img
src={resolveFileRef(post.image)}
alt={post.title}
class="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
/>
</div>
)}
<div class="terminal-document article-content" set:html={renderedContent.code}></div>
</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)]">
file://content/posts/{post.slug}.md
</span>
<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>
</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>
</button>
</div>
</div>
</div>
</TerminalWindow>
<RelatedPosts
currentSlug={post.slug}
currentCategory={post.category}
currentTags={post.tags}
/>
<section class="mt-8">
<Comments postSlug={post.slug} class="terminal-panel" />
</section>
</div>
<TableOfContents />
</div>
</div>
</BaseLayout>

View File

@@ -0,0 +1,258 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
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 type { Category, Post, Tag } from '../../lib/types';
export const prerender = false;
let allPosts: Post[] = [];
let allTags: Tag[] = [];
let allCategories: Category[] = [];
const url = new URL(Astro.request.url);
const selectedSearch = url.searchParams.get('search') || '';
try {
allPosts = selectedSearch ? await api.searchPosts(selectedSearch) : await api.getPosts();
allCategories = await api.getCategories();
const rawTags = await api.getTags();
const seenTagIds = new Set<string>();
allTags = rawTags.filter(tag => {
const key = `${tag.slug}:${tag.name}`.toLowerCase();
if (seenTagIds.has(key)) return false;
seenTagIds.add(key);
return true;
});
} catch (error) {
console.error('API Error:', error);
}
const selectedType = url.searchParams.get('type') || 'all';
const selectedTag = url.searchParams.get('tag') || '';
const selectedCategory = url.searchParams.get('category') || '';
const currentPage = parseInt(url.searchParams.get('page') || '1');
const postsPerPage = 10;
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
const isMatchingTag = (value: string) => value.trim().toLowerCase() === normalizedSelectedTag;
const isSelectedTag = (tag: Tag) =>
tag.name.trim().toLowerCase() === normalizedSelectedTag || tag.slug.trim().toLowerCase() === normalizedSelectedTag;
const filteredPosts = allPosts.filter(post => {
if (selectedType !== 'all' && post.type !== selectedType) return false;
if (selectedTag && !post.tags?.some(isMatchingTag)) return false;
if (selectedCategory && post.category?.toLowerCase() !== selectedCategory.toLowerCase()) return false;
return true;
});
const totalPosts = filteredPosts.length;
const totalPages = Math.ceil(totalPosts / postsPerPage);
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' }
];
const typePromptCommand = `./filter --type ${selectedType || 'all'}`;
const categoryPromptCommand = `./filter --category ${selectedCategory ? `"${selectedCategory}"` : 'all'}`;
const tagPromptCommand = `./filter --tag ${selectedTag ? `"${selectedTag}"` : 'all'}`;
const buildArticlesUrl = ({
type = selectedType,
search = selectedSearch,
tag = selectedTag,
category = selectedCategory,
page,
}: {
type?: string;
search?: string;
tag?: string;
category?: string;
page?: number;
}) => {
const params = new URLSearchParams();
if (type && type !== 'all') params.set('type', type);
if (search) params.set('search', search);
if (tag) params.set('tag', tag);
if (category) params.set('category', category);
if (page && page > 1) params.set('page', String(page));
const queryString = params.toString();
return queryString ? `/articles?${queryString}` : '/articles';
};
---
<BaseLayout 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>
<p class="max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
按类型、分类和标签筛选内容。这里保留更轻的 prompt 标题结构,下方筛选拆成独立区域。
</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} 篇
</span>
{selectedSearch && (
<span class="terminal-stat-pill">
<i class="fas fa-magnifying-glass text-[var(--primary)]"></i>
grep: {selectedSearch}
</span>
)}
{selectedCategory && (
<span class="terminal-stat-pill">
<i class="fas fa-folder-open text-[var(--primary)]"></i>
{selectedCategory}
</span>
)}
{selectedTag && (
<span class="terminal-stat-pill">
<i class="fas fa-hashtag text-[var(--primary)]"></i>
{selectedTag}
</span>
)}
</div>
</div>
</div>
<div class="px-4 pb-2 space-y-4">
<div class="ml-4">
<CommandPrompt command={typePromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
{postTypeFilters.map(filter => (
<FilterPill
href={buildArticlesUrl({ type: filter.id, page: 1 })}
tone="blue"
active={selectedType === filter.id}
>
<i class={`fas ${filter.icon}`}></i>
<span class="font-medium">{filter.name}</span>
</FilterPill>
))}
</div>
</div>
{allCategories.length > 0 && (
<div class="ml-4">
<CommandPrompt command={categoryPromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
<FilterPill
href={buildArticlesUrl({ category: '', page: 1 })}
tone="amber"
active={!selectedCategory}
>
<i class="fas fa-folder-tree"></i>
<span class="font-medium">全部分类</span>
</FilterPill>
{allCategories.map(category => (
<FilterPill
href={buildArticlesUrl({ category: category.name, page: 1 })}
tone="amber"
active={selectedCategory.toLowerCase() === category.name.toLowerCase()}
>
<i class="fas fa-folder-open"></i>
<span class="font-medium">{category.name}</span>
<span class="text-xs text-[var(--text-tertiary)]">{category.count}</span>
</FilterPill>
))}
</div>
</div>
)}
{allTags.length > 0 && (
<div class="ml-4">
<CommandPrompt command={tagPromptCommand} typing={false} />
<div class="mt-3 flex flex-wrap gap-3">
<FilterPill
href={buildArticlesUrl({ tag: '', page: 1 })}
tone="teal"
active={!selectedTag}
>
<i class="fas fa-hashtag"></i>
<span class="font-medium">全部标签</span>
</FilterPill>
{allTags.map(tag => (
<FilterPill
href={buildArticlesUrl({ tag: tag.slug || tag.name, page: 1 })}
tone="teal"
active={isSelectedTag(tag)}
>
<i class="fas fa-hashtag"></i>
<span class="font-medium">{tag.name}</span>
</FilterPill>
))}
</div>
</div>
)}
</div>
<div class="px-4">
{paginatedPosts.length > 0 ? (
<div class="ml-4 mt-4 space-y-4">
{paginatedPosts.map(post => (
<PostCard post={post} selectedTag={selectedTag} highlightTerm={selectedSearch} />
))}
</div>
) : (
<div class="terminal-empty ml-4 mt-4">
<div class="mx-auto flex max-w-md flex-col items-center gap-3">
<span class="terminal-section-icon">
<i class="fas fa-folder-open"></i>
</span>
<h2 class="text-xl font-semibold text-[var(--title-color)]">没有匹配结果</h2>
<p class="text-sm leading-7 text-[var(--text-secondary)]">
当前筛选条件下没有找到文章。可以清空标签或关键字,重新浏览整个内容目录。
</p>
<a href="/articles" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-rotate-left"></i>
<span>reset filters</span>
</a>
</div>
</div>
)}
</div>
{totalPages > 1 && (
<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
</span>
<div class="flex flex-wrap gap-2">
{currentPage > 1 && (
<a
href={buildArticlesUrl({ page: currentPage - 1 })}
class="terminal-action-button"
>
<i class="fas fa-chevron-left"></i>
<span>prev</span>
</a>
)}
{currentPage < totalPages && (
<a
href={buildArticlesUrl({ page: currentPage + 1 })}
class="terminal-action-button terminal-action-button-primary"
>
<span>next</span>
<i class="fas fa-chevron-right"></i>
</a>
)}
</div>
</div>
</div>
)}
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -0,0 +1,94 @@
---
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';
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
try {
categories = await api.getCategories();
} catch (error) {
console.error('Failed to fetch categories:', error);
}
---
<BaseLayout title="分类 - 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">
<CommandPrompt command="ls -la ./categories" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">content taxonomy</div>
<div class="terminal-section-title mt-4">
<span class="terminal-section-icon">
<i class="fas fa-folder-open"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">文章分类</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
按内容主题浏览文章,分类页现在和其他列表页保持同一套终端面板语言。
</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>
<span class="terminal-stat-pill">
<i class="fas fa-terminal text-[var(--primary)]"></i>
<span>快速跳转分类文章</span>
</span>
</div>
</div>
</div>
<div class="px-4">
{categories.length > 0 ? (
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{categories.map(category => (
<a
href={`/articles?category=${encodeURIComponent(category.name)}`}
class="terminal-panel group p-5 transition-all duration-300 hover:-translate-y-1 hover:border-[var(--primary)]"
>
<div class="flex items-start gap-4">
<div class="shrink-0 flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--code-bg)]">
<i class={`fas fa-folder-open text-[var(--primary)] text-lg`}></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<div class="text-[11px] uppercase tracking-[0.22em] text-[var(--text-tertiary)] mb-1">
{category.slug || category.name}
</div>
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors text-lg">
{category.name}
</h3>
</div>
<span class="terminal-chip text-xs py-1 px-2.5">
<span>{category.count} 篇</span>
</span>
</div>
<p class="text-sm leading-6 text-[var(--text-secondary)]">
浏览 {category.name} 主题下的全部文章和更新记录。
</p>
<div class="mt-4 terminal-link-arrow">
<span>查看分类文章</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</div>
</div>
</a>
))}
</div>
) : (
<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>
</div>
)}
</div>
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -0,0 +1,127 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
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 type { AppFriendLink } from '../../lib/api/client';
let siteSettings = DEFAULT_SITE_SETTINGS;
let friendLinks: AppFriendLink[] = [];
let error: string | null = null;
try {
[siteSettings, friendLinks] = await Promise.all([
api.getSiteSettings(),
api.getFriendLinks(),
]);
friendLinks = friendLinks.filter(friend => friend.status === 'approved');
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch friend links';
console.error('Failed to fetch friend links:', e);
}
const categories = [...new Set(friendLinks.map(friend => friend.category || '其他'))];
const groupedLinks = categories.map(category => ({
category,
links: friendLinks.filter(friend => (friend.category || '其他') === category)
}));
---
<BaseLayout title={`友情链接 - ${siteSettings.siteShortName}`} description={`与 ${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">
<CommandPrompt command="cat ./friends.txt" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">network map</div>
<div class="terminal-section-title mt-4">
<span class="terminal-section-icon">
<i class="fas fa-link"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">友情链接</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
这里聚合已经通过审核的站点,也提供统一风格的申请面板,避免列表区和表单区像两个页面。
</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>
<span class="terminal-stat-pill">
<i class="fas fa-check-circle text-[var(--primary)]"></i>
<span>仅展示已通过审核</span>
</span>
</div>
</div>
</div>
{error && (
<div class="px-4 mb-6">
<div class="ml-4 p-4 rounded-lg border border-[var(--danger)]/20 bg-[var(--danger)]/10 text-[var(--danger)] text-sm">
{error}
</div>
</div>
)}
<div class="px-4 space-y-8">
{groupedLinks.map(group => (
<div class="terminal-panel-muted">
<div class="mb-4 flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<span class="flex h-10 w-10 items-center justify-center rounded-2xl bg-[var(--primary)]/10 text-[var(--primary)]">
<i class="fas fa-folder-open"></i>
</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>
</div>
</div>
<span class="terminal-chip text-xs py-1 px-2.5">({group.links.length})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{group.links.map(friend => (
<FriendLinkCard friend={friend} />
))}
</div>
</div>
))}
</div>
<div class="px-4 mt-8 pt-8 border-t border-[var(--border-color)]">
<CommandPrompt command="./apply_friend_link.sh" />
<div class="ml-4 mt-4">
<FriendLinkApplication siteSettings={siteSettings} />
</div>
</div>
<div class="px-4 mt-8 pt-8 border-t border-[var(--border-color)]">
<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>
<p class="mt-3 text-sm text-[var(--text-secondary)] mb-4 leading-6">
欢迎交换友情链接!请确保您的网站满足以下条件:
</p>
<ul class="text-sm text-[var(--text-secondary)] space-y-1 list-disc list-inside">
<li>原创内容为主</li>
<li>网站稳定运行</li>
<li>无不良内容</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}
</p>
</div>
</div>
</div>
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -0,0 +1,208 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
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 FriendLinkCard from '../components/FriendLinkCard.astro';
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
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 type { AppFriendLink } from '../lib/api/client';
import type { Post } from '../lib/types';
export const prerender = false;
const url = new URL(Astro.request.url);
const selectedType = url.searchParams.get('type') || 'all';
let siteSettings = DEFAULT_SITE_SETTINGS;
let allPosts: Post[] = [];
let recentPosts: Post[] = [];
let pinnedPost: Post | null = null;
let tags: string[] = [];
let friendLinks: AppFriendLink[] = [];
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
let apiError: string | null = null;
try {
siteSettings = await api.getSiteSettings();
allPosts = await api.getPosts();
const filteredPosts = selectedType === 'all'
? allPosts
: allPosts.filter(post => post.type === selectedType);
recentPosts = filteredPosts.slice(0, 5);
pinnedPost = filteredPosts.find(post => post.pinned) || null;
tags = (await api.getTags()).map(tag => tag.name);
friendLinks = (await api.getFriendLinks()).filter(friend => friend.status === 'approved');
categories = await api.getCategories();
} catch (error) {
apiError = error instanceof Error ? error.message : 'API unavailable';
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) },
];
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' }
];
---
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<TerminalWindow title={terminalConfig.title} class="w-full">
<div class="mb-6 px-4 overflow-x-auto">
<pre class="font-mono text-xs sm:text-sm text-[var(--primary)] whitespace-pre">{terminalConfig.asciiArt}</pre>
</div>
<div class="mb-8 px-4">
<CommandPrompt command="cat welcome.txt" />
<div class="ml-4">
<p class="text-lg font-bold text-[var(--primary)] mb-1">{siteSettings.heroTitle}</p>
<p class="text-[var(--text-secondary)]">{siteSettings.heroSubtitle}</p>
</div>
</div>
<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 => (
<a
href={link.href}
class="flex items-center gap-2 text-[var(--text)] hover:text-[var(--primary)] transition-colors py-2"
>
<i class={`fas ${link.icon}`}></i>
<span>{link.text}</span>
</a>
))}
</div>
</div>
{apiError && (
<div class="mb-8 px-4">
<div class="ml-4 p-4 rounded-lg border border-[var(--danger)]/20 bg-[var(--danger)]/10 text-[var(--danger)] text-sm">
{apiError}
</div>
</div>
)}
<div id="categories" class="mb-8 px-4">
<CommandPrompt command="ls -l ./categories" />
<div class="ml-4 flex flex-wrap gap-2">
{categories.map(category => (
<FilterPill tone="amber" href={`/articles?category=${encodeURIComponent(category.name)}`}>
<i class="fas fa-folder"></i>
<span>{category.name}</span>
</FilterPill>
))}
</div>
</div>
<div class="mb-4 px-4">
<CommandPrompt command="./filter_posts.sh" />
<div class="ml-4 flex flex-wrap gap-2">
{postTypeFilters.map(filter => (
<FilterPill tone="blue" active={selectedType === filter.id} href={`/?type=${filter.id}`}>
<i class={`fas ${filter.icon}`}></i>
<span>{filter.name}</span>
</FilterPill>
))}
</div>
</div>
{pinnedPost && (
<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="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="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>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-2">{pinnedPost.date} | 阅读时间: {pinnedPost.readTime}</p>
<p class="text-[var(--text-secondary)]">{pinnedPost.description}</p>
</div>
</div>
</div>
)}
<div id="posts" class="mb-10 px-4">
<CommandPrompt command="find ./posts -type f -name *.md | head -n 5" />
<div class="ml-4">
<div class="divide-y divide-[var(--border-color)]">
{recentPosts.map(post => (
<PostCard post={post} />
))}
</div>
<div class="mt-4">
<ViewMoreLink href="/articles" text="查看所有文章" />
</div>
</div>
</div>
<div id="tags" class="mb-10 px-4">
<CommandPrompt command="cat ./tags.txt" />
<div class="ml-4 flex flex-wrap gap-2">
{tags.map(tag => (
<FilterPill tone="teal" href={`/tags?tag=${encodeURIComponent(tag)}`}>
<i class="fas fa-hashtag"></i>
<span>{tag}</span>
</FilterPill>
))}
</div>
</div>
<div class="border-t border-[var(--border-color)] my-10"></div>
<div id="friends" class="mb-10 px-4">
<CommandPrompt command="cat ./friends.txt" />
<div class="ml-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{friendLinks.map(friend => (
<FriendLinkCard friend={friend} />
))}
</div>
<div class="mt-6 ml-4">
<ViewMoreLink href="/friends" text="查看全部友链" />
</div>
</div>
<div class="border-t border-[var(--border-color)] my-10"></div>
<div id="about" class="px-4">
<CommandPrompt command="cat about_me.txt" />
<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>
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">技术栈</h3>
<TechStackList items={techStack} />
</div>
<div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">系统状态</h3>
<StatsList stats={systemStats} />
</div>
</div>
</div>
</div>
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -0,0 +1,299 @@
---
import Layout from '../../layouts/BaseLayout.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
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 type { Review } from '../../lib/api/client';
type ParsedReview = Omit<Review, 'tags'> & {
tags: string[];
};
// Fetch reviews from backend API
let reviews: Awaited<ReturnType<typeof apiClient.getReviews>> = [];
const url = new URL(Astro.request.url);
const selectedType = url.searchParams.get('type') || 'all';
try {
reviews = await apiClient.getReviews();
} catch (error) {
console.error('Failed to fetch reviews:', error);
}
// Parse tags from JSON string
const parsedReviews: ParsedReview[] = reviews.map(r => ({
...r,
tags: r.tags ? JSON.parse(r.tags) as string[] : []
}));
const filteredReviews = selectedType === 'all'
? parsedReviews
: parsedReviews.filter(review => review.review_type === selectedType);
const stats = {
total: filteredReviews.length,
avgRating: filteredReviews.length > 0
? (filteredReviews.reduce((sum, r) => sum + (r.rating || 0), 0) / filteredReviews.length).toFixed(1)
: '0',
completed: filteredReviews.filter(r => r.status === 'completed').length,
inProgress: filteredReviews.filter(r => r.status === 'in-progress').length
};
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 }
];
const typeLabels: Record<string, string> = {
game: '游戏',
anime: '动画',
music: '音乐',
book: '书籍',
movie: '影视'
};
const typeColors: Record<string, string> = {
game: '#4285f4',
anime: '#ff6b6b',
music: '#00ff9d',
book: '#f59e0b',
movie: '#9b59b6'
};
---
<Layout title="评价 | Termi" description="记录游戏、音乐、动画、书籍的体验与评价">
<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">
<div class="px-4 py-4 space-y-6">
<!-- Header Section -->
<div>
<CommandPrompt command="cat README.md" path="~/reviews" />
<div class="terminal-panel ml-4 mt-4">
<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>
<p id="reviews-subtitle" class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
记录游戏、音乐、动画、书籍的体验与感悟
{selectedType !== 'all' && ` · 当前筛选: ${typeLabels[selectedType] || selectedType}`}
</p>
</div>
</div>
</div>
</div>
<div>
<CommandPrompt command="cat stats.json" path="~/reviews" />
<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>
</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>
</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>
</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>
</InfoTile>
</div>
</div>
<div>
<CommandPrompt command="filter --by-type" path="~/reviews" />
<div class="ml-4 mt-4 flex flex-wrap gap-2">
{filters.map(filter => (
<FilterPill
href={filter.id === 'all' ? '/reviews' : `/reviews?type=${filter.id}`}
data-review-filter={filter.id}
data-review-label={filter.name}
tone={filter.id === 'all' ? 'neutral' : filter.id === 'game' ? 'blue' : filter.id === 'book' ? 'amber' : filter.id === 'music' ? 'teal' : 'violet'}
active={selectedType === filter.id}
class="review-filter"
>
<i class={`fas ${filter.icon}`}></i>
{filter.name}
<span class="ml-1 text-[var(--text-tertiary)]">[{filter.count}]</span>
</FilterPill>
))}
</div>
</div>
<div>
<CommandPrompt command="ls -la *.md" path="~/reviews" />
<div class="ml-4 mt-2 space-y-3">
{filteredReviews.length === 0 ? (
<div
id="reviews-empty-state"
class="terminal-empty"
>
<div class="text-3xl text-[var(--primary)] mb-3">
<i class="fas fa-inbox"></i>
</div>
<div class="text-[var(--text-secondary)]">
{parsedReviews.length === 0 ? '暂无评价数据,请检查后端 API 连接' : '当前筛选下暂无评价'}
</div>
</div>
) : (
<>
{filteredReviews.map(review => (
<div
class="review-card terminal-panel group cursor-pointer p-5 transition-all hover:-translate-y-1 hover:border-[var(--primary)]"
data-review-card
data-review-type={review.review_type}
data-review-status={review.status}
data-review-rating={review.rating || 0}
style={`border-left: 3px solid ${typeColors[review.review_type] || '#888'};`}
>
<div class="flex items-start gap-4">
<div class="w-16 h-16 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] flex items-center justify-center text-3xl shrink-0">
{review.cover}
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="terminal-chip text-xs py-1 px-2.5" style={`border-color: ${typeColors[review.review_type] || '#888'}33; color: ${typeColors[review.review_type] || '#888'}; background: ${typeColors[review.review_type] || '#888'}12;`}>
{typeLabels[review.review_type] || review.review_type}
</span>
<h3 class="font-bold text-[var(--title-color)] text-lg truncate">{review.title}</h3>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-3 line-clamp-2 leading-6">{review.description}</p>
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="terminal-chip text-xs py-1 px-2.5">
<i class="fas fa-calendar mr-1"></i>{review.review_date}
</span>
<span class="terminal-chip text-xs py-1 px-2.5 flex items-center gap-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<i class={`fas fa-star ${i < (review.rating || 0) ? 'text-yellow-500' : 'text-[var(--border-color)]'}`}></i>
))}
<span class="ml-1 text-[var(--text-tertiary)]">{review.rating || 0}/5</span>
</span>
</div>
<div class="flex flex-wrap gap-2 mt-3">
{review.tags.map((tag: string) => (
<span class="terminal-chip text-xs py-1 px-2.5">
#{tag}
</span>
))}
</div>
</div>
</div>
</div>
))}
<div
id="reviews-empty-state"
class="terminal-empty hidden"
>
<div class="text-3xl text-[var(--primary)] mb-3">
<i class="fas fa-inbox"></i>
</div>
<div class="text-[var(--text-secondary)]">当前筛选下暂无评价</div>
</div>
</>
)}
</div>
</div>
<!-- Back to Home -->
<div class="pt-4 border-t border-[var(--border-color)]">
<a href="/" class="inline-flex items-center gap-2 text-[var(--text-secondary)] hover:text-[var(--primary)] transition-colors">
<i class="fas fa-chevron-left"></i>
<span class="font-mono">cd ~</span>
</a>
</div>
</div>
</TerminalWindow>
</div>
<script is:inline>
(function() {
const typeLabels = {
game: '游戏',
anime: '动画',
music: '音乐',
book: '书籍',
movie: '影视',
all: '全部'
};
const cards = Array.from(document.querySelectorAll('[data-review-card]'));
const filters = Array.from(document.querySelectorAll('[data-review-filter]'));
const subtitle = document.getElementById('reviews-subtitle');
const totalEl = document.getElementById('reviews-total');
const avgEl = document.getElementById('reviews-average');
const completedEl = document.getElementById('reviews-completed');
const progressEl = document.getElementById('reviews-progress');
const emptyState = document.getElementById('reviews-empty-state');
const baseSubtitle = '记录游戏、音乐、动画、书籍的体验与感悟';
function updateFilterUi(activeType) {
filters.forEach((filter) => {
const isActive = filter.dataset.reviewFilter === activeType;
filter.classList.toggle('is-active', isActive);
});
}
function updateStats(visibleCards) {
const total = visibleCards.length;
const average = total
? (visibleCards.reduce((sum, card) => sum + Number(card.dataset.reviewRating || 0), 0) / total).toFixed(1)
: '0';
const completed = visibleCards.filter((card) => card.dataset.reviewStatus === 'completed').length;
const inProgress = visibleCards.filter((card) => card.dataset.reviewStatus === 'in-progress').length;
if (totalEl) totalEl.textContent = String(total);
if (avgEl) avgEl.textContent = average;
if (completedEl) completedEl.textContent = String(completed);
if (progressEl) progressEl.textContent = String(inProgress);
}
function applyFilter(type, pushState = true) {
const visibleCards = cards.filter((card) => type === 'all' || card.dataset.reviewType === type);
cards.forEach((card) => {
card.style.display = visibleCards.includes(card) ? '' : 'none';
});
if (emptyState) {
emptyState.classList.toggle('hidden', visibleCards.length > 0);
}
updateFilterUi(type);
updateStats(visibleCards);
if (subtitle) {
subtitle.textContent = type === 'all'
? baseSubtitle
: `${baseSubtitle} · 当前筛选: ${typeLabels[type] || type}`;
}
if (pushState) {
const nextUrl = type === 'all' ? '/reviews' : `/reviews?type=${encodeURIComponent(type)}`;
window.history.replaceState({}, '', nextUrl);
}
}
filters.forEach((filter) => {
filter.addEventListener('click', (event) => {
event.preventDefault();
applyFilter(filter.dataset.reviewFilter || 'all');
});
});
applyFilter(new URL(window.location.href).searchParams.get('type') || 'all', false);
})();
</script>
</Layout>

View File

@@ -0,0 +1,157 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
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 type { Post, Tag } from '../../lib/types';
export const prerender = false;
// Fetch tags from backend
let tags: Tag[] = [];
let filteredPosts: Post[] = [];
try {
tags = await apiClient.getTags();
} catch (error) {
console.error('Failed to fetch tags:', error);
}
// Get URL params
const url = new URL(Astro.request.url);
const selectedTag = url.searchParams.get('tag') || '';
const selectedTagToken = selectedTag.trim().toLowerCase();
const isSelectedTag = (tag: Tag) =>
tag.name.trim().toLowerCase() === selectedTagToken || tag.slug.trim().toLowerCase() === selectedTagToken;
// Fetch posts by tag from API if tag is selected
if (selectedTag) {
try {
filteredPosts = await apiClient.getPostsByTag(selectedTag);
} catch (error) {
console.error('Failed to fetch posts by tag:', error);
}
}
---
<BaseLayout title="标签 - 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">
<CommandPrompt command="cat ./tags.txt" />
<div class="terminal-panel ml-4 mt-4">
<div class="terminal-kicker">tag index</div>
<div class="terminal-section-title mt-4">
<span class="terminal-section-icon">
<i class="fas fa-hashtag"></i>
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--title-color)]">标签云</h1>
<p class="mt-2 max-w-2xl text-sm leading-6 text-[var(--text-secondary)]">
用更轻量的关键词维度检索文章。选中标签时,下方结果区会延续同一套终端卡片风格。
</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>
{selectedTag && (
<span class="terminal-stat-pill">
<i class="fas fa-filter text-[var(--primary)]"></i>
<span>当前: #{selectedTag}</span>
</span>
)}
</div>
</div>
</div>
{selectedTag && (
<div class="mb-6 px-4">
<CommandPrompt command={`grep -r "#${selectedTag}" ./posts/`} />
<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} 篇文章
</p>
<FilterPill tone="teal" href="/tags">
<i class="fas fa-times"></i>
<span>清除筛选</span>
</FilterPill>
</div>
</div>
</div>
)}
<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
</div>
<div class="flex flex-wrap gap-2">
{tags.length === 0 ? (
<div class="terminal-empty w-full">
暂无标签数据
</div>
) : (
tags.map(tag => (
<FilterPill
tone="teal"
active={isSelectedTag(tag)}
href={`/tags?tag=${encodeURIComponent(tag.slug || tag.name || '')}`}
>
<i class="fas fa-hashtag"></i>
<span>{tag.name}</span>
</FilterPill>
))
)}
</div>
</div>
</div>
{selectedTag && filteredPosts.length > 0 && (
<div class="px-4">
<div class="border-t border-[var(--border-color)] pt-6">
<CommandPrompt command={`ls -la ./posts/*#${selectedTag}*`} />
<div class="ml-4 mt-4 space-y-4">
{filteredPosts.map(post => (
<a
href={`/articles/${post.slug}`}
class="terminal-panel block p-5 transition-all hover:-translate-y-1 hover:border-[var(--primary)]"
>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="w-2 h-2 rounded-full" style={`background-color: ${post.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}></span>
<h3 class="font-bold text-[var(--title-color)] text-lg">{post.title}</h3>
<span class="terminal-chip text-xs py-1 px-2.5">
<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)] mt-3 leading-6">{post.description}</p>
<div class="mt-4 terminal-link-arrow">
<span>打开文章</span>
<i class="fas fa-arrow-right text-xs"></i>
</div>
</a>
))}
</div>
</div>
</div>
)}
{selectedTag && filteredPosts.length === 0 && (
<div class="px-4">
<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>
</div>
</div>
</div>
)}
</TerminalWindow>
</div>
</BaseLayout>

View File

@@ -0,0 +1,172 @@
---
import Layout from '../../layouts/BaseLayout.astro';
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 type { Post } from '../../lib/types';
let siteSettings = DEFAULT_SITE_SETTINGS;
let posts: Post[] = [];
try {
[siteSettings, posts] = await Promise.all([
api.getSiteSettings(),
api.getPosts(),
]);
} catch (error) {
console.error('Failed to load timeline:', error);
}
const groupedByYear = posts.reduce((acc: Record<number, Post[]>, post) => {
const year = new Date(post.date).getFullYear();
if (!acc[year]) acc[year] = [];
acc[year].push(post);
return acc;
}, {});
const years = Object.keys(groupedByYear).sort((a, b) => Number(b) - Number(a));
const latestYear = years[0] || 'all';
---
<Layout title={`时间轴 | ${siteSettings.siteShortName}`} description={`记录 ${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">
<div>
<CommandPrompt command="cat timeline.log" path="~" />
<div class="terminal-panel ml-4 mt-4">
<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>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
共 {posts.length} 篇内容 · 记录 {siteSettings.ownerName} 的技术成长与生活点滴
</p>
</div>
</div>
</div>
</div>
<div>
<CommandPrompt command="filter --by-year" path="~/timeline" />
<div class="ml-4 mt-4 flex flex-wrap gap-2">
<FilterPill
class="timeline-filter"
tone="neutral"
data-year="all"
active={false}
>
全部
</FilterPill>
{years.map(year => (
<FilterPill
class="timeline-filter"
tone="blue"
data-year={year}
active={year === latestYear}
>
<i class="fas fa-calendar-alt"></i>
{year}
<span class="ml-1 text-[var(--text-tertiary)]">[{groupedByYear[Number(year)].length}]</span>
</FilterPill>
))}
</div>
</div>
<div>
<CommandPrompt command="git log --oneline --all" path="~/timeline" />
<div class="ml-4 mt-2 space-y-8">
{years.map(year => (
<div class="timeline-year-group relative" data-year={year}>
<div class="sticky top-20 z-10 mb-4">
<div class="inline-flex items-center gap-2 rounded-xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-2 font-mono text-sm">
<i class="fas fa-calendar-alt text-[var(--primary)]"></i>
<span class="font-bold text-[var(--text)]">{year}</span>
</div>
</div>
<div class="relative pl-8 space-y-4">
<div class="absolute left-3 top-0 bottom-0 w-px" style="background: linear-gradient(180deg, color-mix(in oklab, var(--primary) 60%, transparent), var(--border-color), transparent);"></div>
{groupedByYear[Number(year)].map(post => (
<div class="relative group">
<div class="absolute -left-[1.625rem] top-4 h-3.5 w-3.5 rounded-full bg-[var(--bg)] border-2 border-[var(--primary)] z-10 group-hover:bg-[var(--primary)] group-hover:scale-125 transition-all"></div>
<a
href={`/articles/${post.slug}`}
class="terminal-panel group flex items-start gap-4 p-4 hover:border-[var(--primary)] hover:translate-x-2 transition-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' })}
</div>
<div class="mt-1 text-2xl font-bold text-[var(--primary)]">
{new Date(post.date).getDate()}
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex flex-wrap items-center gap-2 mb-2">
<span
class="w-2 h-2 rounded-full"
style={`background-color: ${post.type === 'article' ? 'var(--primary)' : 'var(--secondary)'}`}
></span>
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-lg">
{post.title}
</h3>
</div>
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-6">{post.description}</p>
<div class="flex items-center gap-2 mt-2 flex-wrap">
<span class="terminal-chip text-xs py-1 px-2.5">
{post.category}
</span>
<span class="terminal-chip text-xs py-1 px-2.5">
{post.readTime}
</span>
</div>
</div>
</a>
</div>
))}
</div>
</div>
))}
</div>
</div>
<div class="pt-4 border-t border-[var(--border-color)]">
<a href="/" class="inline-flex items-center gap-2 text-[var(--text-secondary)] hover:text-[var(--primary)] transition-colors">
<i class="fas fa-chevron-left"></i>
<span class="font-mono">cd ~</span>
</a>
</div>
</div>
</TerminalWindow>
</div>
</Layout>
<script>
const filterButtons = document.querySelectorAll('.timeline-filter');
const yearGroups = document.querySelectorAll('.timeline-year-group');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
const year = button.getAttribute('data-year');
filterButtons.forEach(item => {
item.classList.remove('is-active');
});
button.classList.add('is-active');
yearGroups.forEach(group => {
const matches = year === 'all' || group.getAttribute('data-year') === year;
group.classList.toggle('hidden', !matches);
});
});
});
</script>

View File

@@ -0,0 +1,67 @@
/* Animations */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes terminal-glow {
0% {
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.4),
0 0 20px rgba(var(--primary-rgb), 0.2),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
100% {
box-shadow:
0 0 12px rgba(var(--primary-rgb), 0.5),
0 0 25px rgba(var(--primary-rgb), 0.3),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
}
@keyframes terminal-glow-dark {
0% {
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.3),
0 0 20px rgba(var(--primary-rgb), 0.15),
0 10px 40px rgba(var(--primary-rgb), 0.1);
}
100% {
box-shadow:
0 0 15px rgba(var(--primary-rgb), 0.5),
0 0 30px rgba(var(--primary-rgb), 0.25),
0 10px 50px rgba(var(--primary-rgb), 0.15);
}
}
/* Smooth transitions for theme changes */
html {
transition: background-color 0.3s ease, color 0.3s ease;
}
body, button, input, select, textarea, a {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
/* Theme toggle animation */
.theme-toggle {
transition: all 0.3s ease;
}
.theme-toggle i {
transition: transform 0.3s ease;
}
.theme-toggle:hover i {
transform: rotate(30deg);
}
/* Fade in animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in {
animation: fadeIn 0.5s ease-out forwards;
}

View File

@@ -0,0 +1,161 @@
/* Color variables - Light/Dark mode */
:root {
color-scheme: light dark;
/* Light mode (default) */
--primary: #4285f4;
--primary-rgb: 66 133 244;
--primary-light: #4285f433;
--primary-dark: #3367d6;
--secondary: #ea580c;
--secondary-rgb: 234 88 12;
--secondary-light: #ea580c33;
--bg: #f3f4f6;
--bg-rgb: 243 244 246;
--bg-secondary: #e5e7eb;
--bg-tertiary: #d1d5db;
--terminal-bg: #ffffff;
--text: #1a1a1a;
--text-rgb: 26 26 26;
--text-secondary: #6b7280;
--text-tertiary: #9ca3af;
--terminal-text: #1a1a1a;
--title-color: #1a1a1a;
--button-text: #1a1a1a;
--border-color: #e5e7eb;
--border-color-rgb: 229 231 235;
--terminal-border: #e5e7eb;
--tag-bg: #f3f4f6;
--tag-text: #1a1a1a;
--header-bg: rgba(249 250 251 / 0.9);
--code-bg: #f3f4f6;
/* Status colors */
--success: #10b981;
--success-rgb: 16 185 129;
--success-light: #d1fae5;
--success-dark: #065f46;
--warning: #f59e0b;
--warning-rgb: 245 158 11;
--warning-light: #fef3c7;
--warning-dark: #92400e;
--danger: #ef4444;
--danger-rgb: 239 68 68;
--danger-light: #fee2e2;
--danger-dark: #991b1b;
--gray-light: #f3f4f6;
--gray-dark: #374151;
/* Terminal buttons */
--btn-close: #ff5f56;
--btn-minimize: #ffbd2e;
--btn-expand: #27c93f;
}
/* Dark mode via class */
html.dark {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255 255 255 / 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255 255 255 / 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: rgba(22 27 34 / 0.9);
--code-bg: #161b22;
/* Status colors - dark mode */
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
/* System preference dark mode */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255 255 255 / 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255 255 255 / 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: rgba(22 27 34 / 0.9);
--code-bg: #161b22;
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
}

View File

@@ -0,0 +1,703 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Base styles */
@layer base {
:root {
--radius: 0.5rem;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
/* Light mode colors */
--primary: #2563eb;
--primary-rgb: 37 99 235;
--primary-light: rgba(37 99 235 / 0.14);
--primary-dark: #1d4ed8;
--secondary: #f97316;
--secondary-rgb: 249 115 22;
--secondary-light: rgba(249 115 22 / 0.14);
--bg: #eef3f8;
--bg-rgb: 238 243 248;
--bg-secondary: #e2e8f0;
--bg-tertiary: #cbd5e1;
--terminal-bg: #f8fbff;
--text: #0f172a;
--text-rgb: 15 23 42;
--text-secondary: #475569;
--text-tertiary: #7c8aa0;
--terminal-text: #0f172a;
--title-color: #0f172a;
--button-text: #0f172a;
--border-color: #d6e0ea;
--border-color-rgb: 214 224 234;
--terminal-border: #d6e0ea;
--tag-bg: #edf3f8;
--tag-text: #0f172a;
--header-bg: rgba(244 248 252 / 0.92);
--code-bg: #eef3f8;
--success: #10b981;
--success-rgb: 16 185 129;
--success-light: #d1fae5;
--success-dark: #065f46;
--warning: #f59e0b;
--warning-rgb: 245 158 11;
--warning-light: #fef3c7;
--warning-dark: #92400e;
--danger: #ef4444;
--danger-rgb: 239 68 68;
--danger-light: #fee2e2;
--danger-dark: #991b1b;
--gray-light: #f3f4f6;
--gray-dark: #374151;
--btn-close: #ff5f56;
--btn-minimize: #ffbd2e;
--btn-expand: #27c93f;
}
html {
scroll-behavior: smooth;
}
body {
@apply bg-bg text-text antialiased;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
}
/* Dark mode via class */
html.dark {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255 255 255 / 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255 255 255 / 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: rgba(22 27 34 / 0.9);
--code-bg: #161b22;
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
/* System preference dark mode */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--primary: #00ff9d;
--primary-rgb: 0 255 157;
--primary-light: #00ff9d33;
--primary-dark: #00b8ff;
--secondary: #00b8ff;
--secondary-rgb: 0 184 255;
--secondary-light: #00b8ff33;
--bg: #0a0e17;
--bg-rgb: 10 14 23;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--terminal-bg: #0d1117;
--text: #e6e6e6;
--text-rgb: 230 230 230;
--text-secondary: #d1d5db;
--text-tertiary: #6b7280;
--terminal-text: #e6e6e6;
--title-color: #ffffff;
--button-text: #e6e6e6;
--border-color: rgba(255 255 255 / 0.1);
--border-color-rgb: 255 255 255;
--terminal-border: rgba(255 255 255 / 0.1);
--tag-bg: #161b22;
--tag-text: #e6e6e6;
--header-bg: rgba(22 27 34 / 0.9);
--code-bg: #161b22;
--success-light: #064e3b;
--success-dark: #d1fae5;
--warning-light: #78350f;
--warning-dark: #fef3c7;
--danger-light: #7f1d1d;
--danger-dark: #fee2e2;
--gray-light: #1f2937;
--gray-dark: #e5e7eb;
}
}
/* Components */
@layer components {
/* Terminal window glow */
.terminal-window {
@apply relative overflow-hidden rounded-2xl;
border: 1px solid color-mix(in oklab, var(--primary) 16%, var(--terminal-border));
box-shadow:
0 18px 40px rgba(var(--text-rgb), 0.06),
0 0 0 1px rgba(255, 255, 255, 0.45);
}
/* Post card hover effect */
.post-card {
@apply relative transition-all duration-300;
}
.post-card::before {
content: '';
@apply absolute left-0 top-0 bottom-0 w-1 rounded-l opacity-0 transition-opacity duration-300;
background-color: var(--post-border-color, var(--primary));
}
.post-card:hover {
@apply translate-x-2;
}
.post-card:hover::before {
@apply opacity-100;
}
/* ASCII art */
.ascii-art {
@apply font-mono text-xs sm:text-sm text-primary whitespace-pre;
}
/* Cursor blink */
.cursor {
@apply inline-block w-2.5 h-4 align-middle ml-0.5;
background-color: var(--primary);
animation: blink 1s infinite;
}
/* Theme toggle */
.theme-toggle {
@apply transition-all duration-300;
}
.theme-toggle i {
@apply transition-transform duration-300;
}
.theme-toggle:hover i {
@apply rotate-[30deg];
}
.terminal-panel {
@apply rounded-xl border p-5;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent)),
linear-gradient(90deg, rgba(var(--primary-rgb), 0.035), transparent 22%, rgba(var(--primary-rgb), 0.02) 78%, transparent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.5),
0 10px 26px rgba(var(--text-rgb), 0.045);
}
.terminal-panel-muted {
@apply rounded-xl border p-4;
border-color: color-mix(in oklab, var(--primary) 6%, 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));
}
.terminal-kicker {
@apply inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-[10px] font-mono uppercase tracking-[0.28em];
color: var(--text-tertiary);
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
}
.terminal-stat-pill {
@apply inline-flex items-center gap-2 rounded-md border px-2.5 py-1 text-[11px] font-medium;
border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color));
background: color-mix(in oklab, var(--primary) 5%, var(--terminal-bg));
color: var(--text-secondary);
}
.terminal-section-title {
@apply flex flex-wrap items-center gap-3;
}
.terminal-section-icon {
@apply flex h-10 w-10 items-center justify-center rounded-lg text-base;
color: var(--primary);
background: color-mix(in oklab, var(--primary) 8%, var(--terminal-bg));
border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color));
}
.terminal-empty {
@apply rounded-xl border border-dashed px-6 py-10 text-center;
border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--header-bg) 78%, transparent);
}
.terminal-chip {
@apply inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-all;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
color: var(--text-secondary);
}
.terminal-chip:hover {
border-color: color-mix(in oklab, var(--primary) 20%, var(--border-color));
color: var(--title-color);
}
.terminal-link-arrow {
@apply inline-flex items-center gap-2 text-sm font-medium;
color: var(--primary);
}
.terminal-toolbar-shell {
@apply relative overflow-hidden rounded-[1.35rem] border p-3;
border-color: color-mix(in oklab, var(--primary) 12%, 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)),
linear-gradient(90deg, rgba(var(--primary-rgb), 0.03), transparent 20%, rgba(var(--primary-rgb), 0.015) 78%, transparent);
box-shadow:
0 12px 30px rgba(var(--text-rgb), 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.42);
}
.terminal-toolbar-shell::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background-image: linear-gradient(rgba(var(--primary-rgb), 0.04) 1px, transparent 1px);
background-size: 100% 14px;
opacity: 0.18;
}
.terminal-toolbar-module {
@apply relative flex items-center gap-3 rounded-lg border px-3 py-2;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.38);
}
.terminal-toolbar-label {
@apply text-[10px] font-mono uppercase tracking-[0.28em];
color: var(--text-tertiary);
}
.terminal-toolbar-iconbtn {
@apply inline-flex h-8 w-8 items-center justify-center rounded-md border transition-all;
border-color: color-mix(in oklab, var(--primary) 6%, var(--border-color));
color: var(--text-secondary);
background: color-mix(in oklab, var(--header-bg) 84%, transparent);
}
.terminal-toolbar-iconbtn:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
color: var(--primary);
background: color-mix(in oklab, var(--primary) 7%, var(--terminal-bg));
}
.terminal-console-input {
@apply min-w-0 flex-1 bg-transparent font-mono text-sm outline-none;
color: var(--text);
}
.terminal-console-input::placeholder {
color: var(--text-tertiary);
}
.terminal-nav-link {
@apply inline-flex shrink-0 items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm transition-all;
border-color: transparent;
color: var(--text-secondary);
background: transparent;
}
.terminal-nav-link:hover {
border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color));
background: color-mix(in oklab, var(--primary) 5%, var(--terminal-bg));
color: var(--title-color);
}
.terminal-nav-link.is-active {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
background: color-mix(in oklab, var(--primary) 8%, var(--terminal-bg));
color: var(--primary);
}
.terminal-filter {
@apply inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] font-mono transition-all;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
color: var(--text-secondary);
letter-spacing: 0.01em;
}
.terminal-filter:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
color: var(--title-color);
background: color-mix(in oklab, var(--primary) 6%, var(--terminal-bg));
}
.terminal-filter.is-active {
border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));
color: var(--primary);
background: color-mix(in oklab, var(--primary) 9%, var(--terminal-bg));
box-shadow: inset 0 0 0 1px rgba(var(--primary-rgb), 0.08);
}
.terminal-action-button {
@apply inline-flex items-center gap-2 rounded-md border px-3 py-2 font-mono text-[12px] uppercase tracking-[0.18em] transition-all;
border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
color: var(--text-secondary);
}
.terminal-action-button:hover {
border-color: color-mix(in oklab, var(--primary) 18%, var(--border-color));
color: var(--title-color);
}
.terminal-action-button-primary {
border-color: color-mix(in oklab, var(--primary) 24%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--primary) 10%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 6%, var(--terminal-bg)));
color: var(--primary);
box-shadow: 0 8px 18px rgba(var(--primary-rgb), 0.08);
}
.terminal-action-button-primary:hover {
color: var(--title-color);
background:
linear-gradient(180deg, color-mix(in oklab, var(--primary) 14%, var(--terminal-bg)), color-mix(in oklab, var(--primary) 8%, var(--terminal-bg)));
}
.terminal-form-label {
@apply mb-2 block text-xs font-mono uppercase tracking-[0.24em];
color: var(--text-tertiary);
}
.terminal-form-input,
.terminal-form-textarea {
@apply w-full rounded-xl border px-3.5 py-2.5 text-sm transition-all outline-none;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 97%, transparent), color-mix(in oklab, var(--header-bg) 92%, transparent));
color: var(--text);
font-family: var(--font-mono);
}
.terminal-form-input::placeholder,
.terminal-form-textarea::placeholder {
color: var(--text-tertiary);
}
.terminal-form-input:focus,
.terminal-form-textarea:focus {
border-color: color-mix(in oklab, var(--primary) 38%, var(--border-color));
box-shadow:
0 0 0 4px rgba(var(--primary-rgb), 0.08),
inset 0 0 0 1px rgba(var(--primary-rgb), 0.08);
}
.terminal-document {
@apply rounded-[1.35rem] border px-5 py-6 sm:px-8 sm:py-8;
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) 92%, transparent));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.28),
0 18px 36px rgba(var(--text-rgb), 0.05);
}
.terminal-document > *:first-child {
@apply mt-0;
}
.terminal-document h1,
.terminal-document h2,
.terminal-document h3,
.terminal-document h4 {
color: var(--title-color);
font-weight: 700;
letter-spacing: -0.02em;
}
.terminal-document h1 {
@apply mt-0 text-3xl;
}
.terminal-document h2 {
@apply mt-10 border-t pt-6 text-2xl;
border-color: color-mix(in oklab, var(--primary) 14%, var(--border-color));
}
.terminal-document h3 {
@apply mt-8 text-xl;
}
.terminal-document p,
.terminal-document ul,
.terminal-document ol,
.terminal-document blockquote,
.terminal-document pre,
.terminal-document table {
@apply mt-5;
}
.terminal-document p,
.terminal-document li {
@apply leading-8;
color: var(--text-secondary);
}
.terminal-document ul,
.terminal-document ol {
@apply space-y-3 pl-6;
}
.terminal-document ul {
list-style: square;
}
.terminal-document ol {
list-style: decimal;
}
.terminal-document li::marker {
color: var(--primary);
}
.terminal-document a {
color: var(--primary);
text-decoration: underline;
text-decoration-color: color-mix(in oklab, var(--primary) 40%, transparent);
text-underline-offset: 0.22em;
}
.terminal-document strong {
color: var(--title-color);
}
.terminal-document code:not(pre code) {
@apply rounded-md px-1.5 py-0.5 text-[0.92em];
background: color-mix(in oklab, var(--primary) 10%, var(--header-bg));
color: var(--primary);
font-family: var(--font-mono);
}
.terminal-document pre,
.terminal-document .astro-code {
@apply overflow-x-auto rounded-2xl border p-4;
border-color: color-mix(in oklab, var(--primary) 16%, var(--border-color));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.terminal-document blockquote {
@apply rounded-r-2xl border-l-4 px-5 py-4;
border-left-color: var(--primary);
background: color-mix(in oklab, var(--primary) 8%, var(--header-bg));
color: var(--text);
}
.terminal-document hr {
@apply my-8 border-0 border-t;
border-color: color-mix(in oklab, var(--primary) 12%, var(--border-color));
}
.terminal-document table {
@apply w-full overflow-hidden rounded-2xl border;
border-color: var(--border-color);
}
.terminal-document th,
.terminal-document td {
@apply px-4 py-3 text-left text-sm;
border-bottom: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
}
.terminal-document th {
background: color-mix(in oklab, var(--primary) 8%, var(--header-bg));
color: var(--title-color);
}
.terminal-document td {
color: var(--text-secondary);
}
.terminal-console-list-item {
@apply flex items-start justify-between gap-4 rounded-xl border px-4 py-3;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
}
.ui-filter-pill {
--pill-rgb: var(--primary-rgb);
--pill-fg: var(--text-secondary);
@apply inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] transition-all;
border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 12%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 3%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 1%, var(--header-bg)));
color: var(--pill-fg);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32);
font-family: var(--font-mono);
letter-spacing: 0.01em;
}
.ui-filter-pill:hover {
border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 18%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 5%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 3%, var(--header-bg)));
color: var(--title-color);
}
.ui-filter-pill.is-active,
.ui-filter-pill:has(input:checked) {
border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 28%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 9%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 5%, var(--header-bg)));
box-shadow:
inset 0 0 0 1px rgba(var(--pill-rgb), 0.08),
0 6px 14px rgba(var(--text-rgb), 0.03);
color: color-mix(in oklab, rgb(var(--pill-rgb)) 72%, var(--title-color));
}
.ui-filter-pill--blue {
--pill-rgb: 59 130 246;
--pill-fg: #315ea8;
}
.ui-filter-pill--amber {
--pill-rgb: 217 119 6;
--pill-fg: #9a5a12;
}
.ui-filter-pill--teal {
--pill-rgb: 13 148 136;
--pill-fg: #0f766e;
}
.ui-filter-pill--violet {
--pill-rgb: 124 58 237;
--pill-fg: #6d46c3;
}
.ui-filter-pill--neutral {
--pill-rgb: 100 116 139;
--pill-fg: var(--text-secondary);
}
.ui-info-tile {
--tile-rgb: var(--primary-rgb);
@apply rounded-xl border transition-all;
border-color: color-mix(in oklab, rgb(var(--tile-rgb)) 10%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, var(--terminal-bg) 98%, transparent), color-mix(in oklab, rgb(var(--tile-rgb)) 1%, var(--header-bg)));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.ui-info-tile:hover {
border-color: color-mix(in oklab, rgb(var(--tile-rgb)) 18%, var(--border-color));
}
.ui-info-tile--row {
@apply flex items-center justify-between gap-3 px-3.5 py-2.5;
}
.ui-info-tile--grid {
@apply flex items-center gap-3 px-3.5 py-3;
}
.ui-info-tile--stack {
@apply px-3.5 py-3 text-left;
}
.ui-info-tile--blue {
--tile-rgb: 59 130 246;
}
.ui-info-tile--amber {
--tile-rgb: 217 119 6;
}
.ui-info-tile--teal {
--tile-rgb: 13 148 136;
}
.ui-info-tile--violet {
--tile-rgb: 124 58 237;
}
.ui-info-tile--neutral {
--tile-rgb: 100 116 139;
}
}
/* Animations */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes terminal-glow {
0% {
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.4),
0 0 20px rgba(var(--primary-rgb), 0.2),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
100% {
box-shadow:
0 0 12px rgba(var(--primary-rgb), 0.5),
0 0 25px rgba(var(--primary-rgb), 0.3),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
}
/* Smooth theme transitions */
html, body, button, input, select, textarea, a {
@apply transition-colors duration-300 ease-out;
}

View File

@@ -0,0 +1,88 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import './colors.css';
@import './animations.css';
:root {
--radius: 0.5rem;
--font-mono: 'JetBrains Mono', 'Fira Code', monospace;
}
html {
scroll-behavior: smooth;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@apply bg-[var(--bg)] text-[var(--text)];
}
/* Terminal glow effect */
.terminal-window {
@apply relative overflow-hidden;
border: 1px solid var(--primary);
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.4),
0 0 20px rgba(var(--primary-rgb), 0.2),
0 10px 30px rgba(var(--primary-rgb), 0.2);
animation: terminal-glow 4s ease-in-out infinite alternate;
}
@keyframes terminal-glow {
0% {
box-shadow:
0 0 8px rgba(var(--primary-rgb), 0.4),
0 0 20px rgba(var(--primary-rgb), 0.2),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
100% {
box-shadow:
0 0 12px rgba(var(--primary-rgb), 0.5),
0 0 25px rgba(var(--primary-rgb), 0.3),
0 10px 30px rgba(var(--primary-rgb), 0.2);
}
}
/* Smooth transitions */
.transition-theme {
@apply transition-colors duration-300 ease-out;
}
/* Post card hover */
.post-card {
@apply relative;
}
.post-card::before {
content: '';
@apply absolute left-0 top-0 bottom-0 w-1 rounded-l opacity-0 transition-opacity duration-300;
background-color: var(--post-border-color, var(--primary));
}
.post-card:hover {
@apply translate-x-2;
}
.post-card:hover::before {
@apply opacity-100;
}
/* ASCII art */
.ascii-art {
font-family: 'Courier New', monospace;
@apply text-xs sm:text-sm text-[var(--primary)] whitespace-pre;
}
/* Cursor blink */
.cursor {
@apply inline-block w-2.5 h-4 align-middle ml-0.5;
background-color: var(--primary);
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}

View File

@@ -0,0 +1,40 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}',
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: 'var(--primary)',
rgb: 'var(--primary-rgb)',
light: 'var(--primary-light)',
dark: 'var(--primary-dark)',
},
bg: {
DEFAULT: 'var(--bg)',
secondary: 'var(--bg-secondary)',
tertiary: 'var(--bg-tertiary)',
terminal: 'var(--terminal-bg)',
},
text: {
DEFAULT: 'var(--text)',
secondary: 'var(--text-secondary)',
tertiary: 'var(--text-tertiary)',
},
border: {
DEFAULT: 'var(--border-color)',
terminal: 'var(--terminal-border)',
},
},
fontFamily: {
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
},
},
plugins: [
require('@tailwindcss/typography'),
],
}

5
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist", "src_backup"]
}