chore: reorganize project into monorepo
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user