chore: reorganize project into monorepo
This commit is contained in:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
43
frontend/README.md
Normal 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
14
frontend/astro.config.mjs
Normal 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
6868
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/package.json
Normal file
29
frontend/package.json
Normal 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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 655 B |
9
frontend/public/favicon.svg
Normal file
9
frontend/public/favicon.svg
Normal 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 |
46
frontend/src/components/BackToTop.astro
Normal file
46
frontend/src/components/BackToTop.astro
Normal 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>
|
||||
41
frontend/src/components/CodeBlock.astro
Normal file
41
frontend/src/components/CodeBlock.astro
Normal 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>
|
||||
61
frontend/src/components/CodeCopyButton.astro
Normal file
61
frontend/src/components/CodeCopyButton.astro
Normal 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>
|
||||
334
frontend/src/components/Comments.astro
Normal file
334
frontend/src/components/Comments.astro
Normal 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>
|
||||
79
frontend/src/components/Footer.astro
Normal file
79
frontend/src/components/Footer.astro
Normal 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)]">© {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>
|
||||
258
frontend/src/components/FriendLinkApplication.astro
Normal file
258
frontend/src/components/FriendLinkApplication.astro
Normal 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>
|
||||
70
frontend/src/components/FriendLinkCard.astro
Normal file
70
frontend/src/components/FriendLinkCard.astro
Normal 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>
|
||||
507
frontend/src/components/Header.astro
Normal file
507
frontend/src/components/Header.astro
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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>
|
||||
141
frontend/src/components/Lightbox.astro
Normal file
141
frontend/src/components/Lightbox.astro
Normal 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>
|
||||
112
frontend/src/components/PostCard.astro
Normal file
112
frontend/src/components/PostCard.astro
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
|
||||
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>
|
||||
45
frontend/src/components/ReadingProgress.astro
Normal file
45
frontend/src/components/ReadingProgress.astro
Normal 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>
|
||||
98
frontend/src/components/RelatedPosts.astro
Normal file
98
frontend/src/components/RelatedPosts.astro
Normal 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>
|
||||
)}
|
||||
21
frontend/src/components/StatsList.astro
Normal file
21
frontend/src/components/StatsList.astro
Normal 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>
|
||||
97
frontend/src/components/TableOfContents.astro
Normal file
97
frontend/src/components/TableOfContents.astro
Normal 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>
|
||||
28
frontend/src/components/TechStackList.astro
Normal file
28
frontend/src/components/TechStackList.astro
Normal 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>
|
||||
38
frontend/src/components/interactive/BackToTop.svelte
Normal file
38
frontend/src/components/interactive/BackToTop.svelte
Normal 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}
|
||||
60
frontend/src/components/interactive/ThemeToggle.svelte
Normal file
60
frontend/src/components/interactive/ThemeToggle.svelte
Normal 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>
|
||||
146
frontend/src/components/ui/CommandPrompt.astro
Normal file
146
frontend/src/components/ui/CommandPrompt.astro
Normal 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>
|
||||
33
frontend/src/components/ui/FilterPill.astro
Normal file
33
frontend/src/components/ui/FilterPill.astro
Normal 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>
|
||||
)}
|
||||
35
frontend/src/components/ui/InfoTile.astro
Normal file
35
frontend/src/components/ui/InfoTile.astro
Normal 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>
|
||||
)}
|
||||
44
frontend/src/components/ui/TerminalButton.astro
Normal file
44
frontend/src/components/ui/TerminalButton.astro
Normal 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>
|
||||
)}
|
||||
34
frontend/src/components/ui/TerminalWindow.astro
Normal file
34
frontend/src/components/ui/TerminalWindow.astro
Normal 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>
|
||||
16
frontend/src/components/ui/ViewMoreLink.astro
Normal file
16
frontend/src/components/ui/ViewMoreLink.astro
Normal 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>
|
||||
220
frontend/src/layouts/BaseLayout.astro
Normal file
220
frontend/src/layouts/BaseLayout.astro
Normal 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>
|
||||
404
frontend/src/lib/api/client.ts
Normal file
404
frontend/src/lib/api/client.ts
Normal 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;
|
||||
167
frontend/src/lib/config/terminal.ts
Normal file
167
frontend/src/lib/config/terminal.ts
Normal 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'
|
||||
}
|
||||
};
|
||||
435
frontend/src/lib/styles/theme.css
Normal file
435
frontend/src/lib/styles/theme.css
Normal 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);
|
||||
}
|
||||
88
frontend/src/lib/types/index.ts
Normal file
88
frontend/src/lib/types/index.ts
Normal 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;
|
||||
}
|
||||
194
frontend/src/lib/utils/data.ts
Normal file
194
frontend/src/lib/utils/data.ts
Normal 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;
|
||||
}
|
||||
94
frontend/src/lib/utils/index.ts
Normal file
94
frontend/src/lib/utils/index.ts
Normal 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)';
|
||||
}
|
||||
156
frontend/src/pages/404.astro
Normal file
156
frontend/src/pages/404.astro
Normal 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>
|
||||
172
frontend/src/pages/about/index.astro
Normal file
172
frontend/src/pages/about/index.astro
Normal 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>
|
||||
331
frontend/src/pages/admin.astro
Normal file
331
frontend/src/pages/admin.astro
Normal 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>
|
||||
165
frontend/src/pages/articles/[slug].astro
Normal file
165
frontend/src/pages/articles/[slug].astro
Normal 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>
|
||||
258
frontend/src/pages/articles/index.astro
Normal file
258
frontend/src/pages/articles/index.astro
Normal 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>
|
||||
94
frontend/src/pages/categories/index.astro
Normal file
94
frontend/src/pages/categories/index.astro
Normal 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>
|
||||
127
frontend/src/pages/friends/index.astro
Normal file
127
frontend/src/pages/friends/index.astro
Normal 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>
|
||||
208
frontend/src/pages/index.astro
Normal file
208
frontend/src/pages/index.astro
Normal 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>
|
||||
299
frontend/src/pages/reviews/index.astro
Normal file
299
frontend/src/pages/reviews/index.astro
Normal 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>
|
||||
157
frontend/src/pages/tags/index.astro
Normal file
157
frontend/src/pages/tags/index.astro
Normal 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>
|
||||
172
frontend/src/pages/timeline/index.astro
Normal file
172
frontend/src/pages/timeline/index.astro
Normal 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>
|
||||
67
frontend/src/styles/animations.css
Normal file
67
frontend/src/styles/animations.css
Normal 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;
|
||||
}
|
||||
161
frontend/src/styles/colors.css
Normal file
161
frontend/src/styles/colors.css
Normal 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;
|
||||
}
|
||||
}
|
||||
703
frontend/src/styles/global.css
Normal file
703
frontend/src/styles/global.css
Normal 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;
|
||||
}
|
||||
88
frontend/src/styles/main.css
Normal file
88
frontend/src/styles/main.css
Normal 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; }
|
||||
}
|
||||
40
frontend/tailwind.config.js
Normal file
40
frontend/tailwind.config.js
Normal 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
5
frontend/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist", "src_backup"]
|
||||
}
|
||||
Reference in New Issue
Block a user