feat: ship blog platform admin and deploy stack

This commit is contained in:
2026-03-31 21:48:39 +08:00
parent a9a05aa105
commit 313f174fbc
210 changed files with 25476 additions and 5803 deletions

View File

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

View File

@@ -1,5 +1,5 @@
---
import { API_BASE_URL, apiClient } from '../lib/api/client';
import { apiClient, resolvePublicApiBaseUrl } from '../lib/api/client';
import { getI18n } from '../lib/i18n';
import type { Comment } from '../lib/api/client';
@@ -10,6 +10,7 @@ interface Props {
const { postSlug, class: className = '' } = Astro.props;
const { locale, t } = getI18n(Astro);
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
let comments: Comment[] = [];
let error: string | null = null;
@@ -35,7 +36,7 @@ function formatCommentDate(dateStr: string): string {
}
---
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={API_BASE_URL}>
<div class={`terminal-comments ${className}`} data-post-slug={postSlug} data-api-base={publicApiBaseUrl}>
<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">
@@ -104,6 +105,35 @@ function formatCommentDate(dateStr: string): string {
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">{t('comments.maxChars')}</p>
</div>
<div class="hidden" aria-hidden="true">
<label>
Website
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</label>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
验证码
</p>
<button type="button" id="refresh-captcha" class="terminal-action-button px-3 py-2 text-xs">
<i class="fas fa-rotate-right"></i>
<span>刷新</span>
</button>
</div>
<p id="captcha-question" class="mt-2 text-sm text-[var(--text-secondary)]">加载中...</p>
<input type="hidden" name="captchaToken" />
<input
type="text"
name="captchaAnswer"
required
inputmode="numeric"
placeholder="请输入上方答案"
class="mt-3 terminal-form-input"
/>
</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)]">
{t('common.reply')} -> <span id="reply-target" class="font-medium text-[var(--primary)]"></span>
@@ -209,8 +239,12 @@ function formatCommentDate(dateStr: string): string {
const cancelReply = document.getElementById('cancel-reply');
const replyBtns = document.querySelectorAll('.reply-btn');
const messageBox = document.getElementById('comment-message');
const captchaQuestion = document.getElementById('captcha-question');
const refreshCaptchaBtn = document.getElementById('refresh-captcha');
const postSlug = wrapper?.getAttribute('data-post-slug') || '';
const apiBase = wrapper?.getAttribute('data-api-base') || '/api';
const captchaTokenInput = form?.querySelector('input[name=\"captchaToken\"]') as HTMLInputElement | null;
const captchaAnswerInput = form?.querySelector('input[name=\"captchaAnswer\"]') as HTMLInputElement | null;
function showMessage(message: string, type: 'success' | 'error' | 'info') {
if (!messageBox) return;
@@ -251,6 +285,37 @@ function formatCommentDate(dateStr: string): string {
replyingTo?.removeAttribute('data-reply-to');
}
async function loadCaptcha(showError = true) {
if (!captchaQuestion || !captchaTokenInput) {
return;
}
captchaQuestion.textContent = '加载中...';
captchaTokenInput.value = '';
if (captchaAnswerInput) {
captchaAnswerInput.value = '';
}
try {
const response = await fetch(`${apiBase}/comments/captcha`);
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json() as { token?: string; question?: string };
captchaTokenInput.value = payload.token || '';
captchaQuestion.textContent = payload.question || '请刷新验证码';
} catch (error) {
captchaQuestion.textContent = '验证码加载失败,请刷新重试';
if (showError) {
showMessage(
t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }),
'error'
);
}
}
}
toggleBtn?.addEventListener('click', () => {
formContainer?.classList.toggle('hidden');
if (!formContainer?.classList.contains('hidden')) {
@@ -285,6 +350,10 @@ function formatCommentDate(dateStr: string): string {
resetReply();
});
refreshCaptchaBtn?.addEventListener('click', () => {
void loadCaptcha(false);
});
form?.addEventListener('submit', async (e) => {
e.preventDefault();
@@ -306,6 +375,9 @@ function formatCommentDate(dateStr: string): string {
content: formData.get('content'),
scope: 'article',
replyToCommentId: replyToId ? Number(replyToId) : null,
captchaToken: formData.get('captchaToken'),
captchaAnswer: formData.get('captchaAnswer'),
website: formData.get('website'),
}),
});
@@ -318,8 +390,10 @@ function formatCommentDate(dateStr: string): string {
resetReply();
formContainer?.classList.add('hidden');
showMessage(t('comments.submitSuccess'), 'success');
void loadCaptcha(false);
} catch (error) {
showMessage(t('comments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
void loadCaptcha(false);
}
});
@@ -335,4 +409,6 @@ function formatCommentDate(dateStr: string): string {
}
});
});
void loadCaptcha(false);
</script>

View File

@@ -1,5 +1,5 @@
---
import { API_BASE_URL, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../lib/api/client';
import { getI18n } from '../lib/i18n';
import type { SiteSettings } from '../lib/types';
@@ -10,11 +10,12 @@ interface Props {
const { class: className = '', siteSettings = DEFAULT_SITE_SETTINGS } = Astro.props;
const { t } = getI18n(Astro);
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
---
<div
class={`terminal-friend-link-form ${className}`}
data-api-base={API_BASE_URL}
data-api-base={publicApiBaseUrl}
data-site-name={siteSettings.siteName}
data-site-url={siteSettings.siteUrl}
data-site-description={siteSettings.siteDescription}

View File

@@ -43,9 +43,9 @@ const { t } = getI18n(Astro);
<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">
<h3 class="font-bold text-[var(--title-color)] group-hover:text-[var(--primary)] transition-colors truncate text-base">
{friend.name}
</h4>
</h3>
<i class="fas fa-external-link-alt text-xs text-[var(--text-tertiary)] opacity-0 group-hover:opacity-100 transition-opacity"></i>
</div>

View File

@@ -1,7 +1,8 @@
---
import { API_BASE_URL } from '../lib/api/client';
import { resolvePublicApiBaseUrl } from '../lib/api/client';
import { terminalConfig } from '../lib/config/terminal';
import { getI18n, SUPPORTED_LOCALES } from '../lib/i18n';
import ThemeToggle from './interactive/ThemeToggle.svelte';
import type { SiteSettings } from '../lib/types';
interface Props {
@@ -19,6 +20,7 @@ const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter(
(item) => item?.title?.trim() && item?.url?.trim()
);
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
const hasMusicPlaylist = musicPlaylist.length > 0;
const currentMusicTrack = hasMusicPlaylist ? musicPlaylist[0] : null;
const navItems = [
@@ -89,7 +91,7 @@ const currentNavLabel =
placeholder={t('header.searchPlaceholderKeyword')}
class="terminal-console-input"
/>
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8">
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8" aria-label="Search">
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
</button>
</div>
@@ -126,13 +128,13 @@ const currentNavLabel =
{currentMusicTrack?.title || '未配置曲目'}
</p>
<div class="mt-1 flex items-center gap-1">
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" disabled={!hasMusicPlaylist}>
<button id="desktop-music-prev" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Previous track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-backward text-[11px]"></i>
</button>
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" disabled={!hasMusicPlaylist}>
<button id="desktop-music-play" class="terminal-toolbar-iconbtn h-7 w-7 bg-[var(--primary)]/12 text-[var(--primary)]" style="border-color: color-mix(in oklab, var(--primary) 28%, var(--border-color));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
<i class="fas fa-play text-[11px]" id="desktop-music-play-icon"></i>
</button>
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" disabled={!hasMusicPlaylist}>
<button id="desktop-music-next" class="terminal-toolbar-iconbtn h-7 w-7" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-[11px]"></i>
</button>
</div>
@@ -169,17 +171,15 @@ const currentNavLabel =
</div>
<div class="relative shrink-0">
<button
id="theme-toggle"
class="theme-toggle terminal-toolbar-iconbtn h-8 w-8 shrink-0"
aria-label={t('header.themeToggle')}
title={t('header.themeToggle')}
>
<i id="theme-icon" class="fas fa-desktop text-sm text-[var(--text-secondary)]"></i>
<span id="theme-toggle-label" class="sr-only">
{t('header.themeSystem')}
</span>
</button>
<ThemeToggle
client:load
labels={{
toggle: t('header.themeToggle'),
system: t('header.themeSystem'),
light: t('header.themeLight'),
dark: t('header.themeDark'),
}}
/>
</div>
<button
@@ -233,7 +233,7 @@ const currentNavLabel =
placeholder={t('header.searchPlaceholderKeyword')}
class="terminal-console-input"
/>
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn">
<button id="mobile-search-btn" class="terminal-toolbar-iconbtn" aria-label="Search">
<i id="mobile-search-btn-icon" class="fas fa-search text-sm"></i>
</button>
</div>
@@ -284,16 +284,16 @@ const currentNavLabel =
<div class="min-w-0 flex-1">
<div class="terminal-toolbar-label">{t('header.musicPanel')}</div>
<div class="mt-1 flex items-center gap-2">
<button id="music-prev" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
<button id="music-prev" class="terminal-toolbar-iconbtn" aria-label="Previous track" disabled={!hasMusicPlaylist}>
<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));" disabled={!hasMusicPlaylist}>
<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));" aria-label="Play or pause" disabled={!hasMusicPlaylist}>
<i class="fas fa-play text-xs" id="music-play-icon"></i>
</button>
<button id="music-next" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
<button id="music-next" class="terminal-toolbar-iconbtn" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-xs"></i>
</button>
<button id="music-volume" class="terminal-toolbar-iconbtn" disabled={!hasMusicPlaylist}>
<button id="music-volume" class="terminal-toolbar-iconbtn" aria-label="Mute or unmute" disabled={!hasMusicPlaylist}>
<i class="fas fa-volume-up text-xs"></i>
</button>
</div>
@@ -337,88 +337,9 @@ const currentNavLabel =
</div>
</header>
<script is:inline define:vars={{ apiBase: API_BASE_URL, musicPlaylistPayload }}>
<script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}>
const t = window.__termiTranslate;
// Theme selection
function initThemeToggle() {
const themeToggle = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');
const themeToggleLabel = document.getElementById('theme-toggle-label');
const themeApi = window.__termiTheme;
if (!themeToggle || !themeIcon || !themeApi) {
return;
}
if (themeToggle.dataset.bound === 'true') {
return;
}
themeToggle.dataset.bound = 'true';
const themeMeta = {
light: {
iconClass: 'fas fa-sun text-sm',
color: 'var(--secondary)',
label: t('header.themeLight'),
},
dark: {
iconClass: 'fas fa-moon text-sm',
color: 'var(--primary)',
label: t('header.themeDark'),
},
system: {
iconClass: 'fas fa-desktop text-sm',
color: 'var(--text-secondary)',
label: t('header.themeSystem'),
},
};
function updateThemeUI(detail = null) {
const mode = detail?.mode || themeApi.getMode();
const resolved = detail?.resolved || themeApi.resolveTheme(mode);
const modeMeta = themeMeta[mode] || themeMeta.system;
const modeLabel = themeMeta[mode]?.label || themeMeta.system.label;
const resolvedLabel = resolved === 'dark' ? t('header.themeDark') : t('header.themeLight');
themeIcon.className = modeMeta.iconClass;
themeIcon.style.color = modeMeta.color;
if (themeToggleLabel) {
themeToggleLabel.textContent = `${modeLabel} / ${resolvedLabel}`;
}
const toggleTitle = `${t('header.themeToggle')} · ${modeLabel} / ${resolvedLabel}`;
themeToggle.setAttribute('aria-label', toggleTitle);
themeToggle.setAttribute('title', toggleTitle);
}
themeToggle.addEventListener('click', function(event) {
event.preventDefault();
const currentMode = themeApi.getMode();
const nextMode =
currentMode === 'system' ? 'light' :
currentMode === 'light' ? 'dark' :
'system';
themeApi.applyTheme(nextMode);
});
window.addEventListener('termi:theme-change', function(event) {
updateThemeUI(event.detail);
});
updateThemeUI(themeApi.syncTheme());
}
// Run immediately if DOM is ready, otherwise wait
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initThemeToggle);
} else {
initThemeToggle();
}
// Site Menu
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
const mobileMenu = document.getElementById('mobile-menu');
@@ -689,7 +610,7 @@ const currentNavLabel =
return buildLocalizedUrl(
currentSearchMode === 'ai'
? `/ask?q=${encodeURIComponent(query)}`
: `/articles?search=${encodeURIComponent(query)}`
: `/search?q=${encodeURIComponent(query)}`
);
}
@@ -845,7 +766,7 @@ const currentNavLabel =
${escapeHtml(t('header.liveResults'))}
</div>
${itemsHtml}
<a href="${buildLocalizedUrl(`/articles?search=${encodeURIComponent(query)}`)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
<a href="${buildLocalizedUrl(`/search?q=${encodeURIComponent(query)}`)}" class="block px-4 py-3 text-sm font-medium text-[var(--primary)] transition-colors hover:bg-[var(--header-bg)]">
${escapeHtml(t('header.searchAllResults'))}
</a>
${aiFooter}

View File

@@ -1,5 +1,5 @@
---
import { API_BASE_URL } from '../lib/api/client';
import { resolvePublicApiBaseUrl } from '../lib/api/client';
import { getI18n } from '../lib/i18n';
interface Props {
@@ -9,12 +9,13 @@ interface Props {
const { postSlug, class: className = '' } = Astro.props;
const { t } = getI18n(Astro);
const publicApiBaseUrl = resolvePublicApiBaseUrl(Astro.url);
---
<div
class={`paragraph-comments-shell ${className}`}
data-post-slug={postSlug}
data-api-base={API_BASE_URL}
data-api-base={publicApiBaseUrl}
data-storage-key={`termi:paragraph-comments:${postSlug}`}
>
<div class="paragraph-comments-toolbar terminal-panel-muted">
@@ -335,6 +336,33 @@ const { t } = getI18n(Astro);
<p class="mt-2 text-right text-xs text-[var(--text-tertiary)]">${escapeHtml(t('paragraphComments.maxChars'))}</p>
</div>
<div class="hidden" aria-hidden="true">
<label>
Website
<input type="text" name="website" tabindex="-1" autocomplete="off" />
</label>
</div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--header-bg)]/60 px-4 py-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--text-tertiary)]">验证码</p>
<button type="button" class="terminal-action-button px-3 py-2 text-xs" data-refresh-captcha>
<i class="fas fa-rotate-right"></i>
<span>刷新</span>
</button>
</div>
<p class="mt-2 text-sm text-[var(--text-secondary)]" data-captcha-question>加载中...</p>
<input type="hidden" name="captchaToken" />
<input
type="text"
name="captchaAnswer"
required
inputmode="numeric"
placeholder="请输入上方答案"
class="mt-3 terminal-form-input"
/>
</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>
@@ -359,6 +387,10 @@ const { t } = getI18n(Astro);
const replyBanner = panel.querySelector('[data-reply-banner]') as HTMLElement;
const replyTarget = panel.querySelector('[data-reply-target]') as HTMLElement;
const focusButton = panel.querySelector('[data-focus-paragraph]') as HTMLButtonElement;
const captchaQuestion = panel.querySelector('[data-captcha-question]') as HTMLElement;
const refreshCaptchaButton = panel.querySelector('[data-refresh-captcha]') as HTMLButtonElement;
const captchaTokenInput = form.querySelector('input[name=\"captchaToken\"]') as HTMLInputElement;
const captchaAnswerInput = form.querySelector('input[name=\"captchaAnswer\"]') as HTMLInputElement;
function clearStatus() {
statusBox.className = 'paragraph-comment-status hidden';
@@ -370,6 +402,37 @@ const { t } = getI18n(Astro);
statusBox.textContent = message;
}
async function loadCaptcha(showStatusOnError = true) {
if (!captchaQuestion || !captchaTokenInput || !captchaAnswerInput) {
return;
}
captchaQuestion.textContent = '加载中...';
captchaTokenInput.value = '';
captchaAnswerInput.value = '';
try {
const response = await fetch(`${apiBase}/comments/captcha`);
if (!response.ok) {
throw new Error(await response.text());
}
const payload = await response.json() as { token?: string; question?: string };
captchaTokenInput.value = payload.token || '';
captchaQuestion.textContent = payload.question || '请刷新验证码';
} catch (error) {
captchaQuestion.textContent = '验证码加载失败,请刷新重试';
if (showStatusOnError) {
setStatus(
t('paragraphComments.submitFailed', {
message: error instanceof Error ? error.message : t('common.unknownError'),
}),
'error'
);
}
}
}
function resetReplyState() {
activeReplyToCommentId = null;
replyBanner.classList.add('hidden');
@@ -579,6 +642,9 @@ const { t } = getI18n(Astro);
descriptor.element.insertAdjacentElement('afterend', panel);
panel.classList.remove('hidden');
panel.dataset.paragraphKey = paragraphKey;
if (!captchaTokenInput.value) {
await loadCaptcha(false);
}
paragraphDescriptors.forEach((item, key) => {
item.element.classList.toggle('is-comment-focused', key === paragraphKey);
@@ -685,6 +751,10 @@ const { t } = getI18n(Astro);
descriptor?.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
refreshCaptchaButton?.addEventListener('click', () => {
void loadCaptcha(false);
});
form.addEventListener('submit', async event => {
event.preventDefault();
@@ -718,6 +788,9 @@ const { t } = getI18n(Astro);
paragraphKey: descriptor.key,
paragraphExcerpt: descriptor.excerpt,
replyToCommentId: activeReplyToCommentId,
captchaToken: formData.get('captchaToken'),
captchaAnswer: formData.get('captchaAnswer'),
website: formData.get('website'),
}),
});
@@ -741,8 +814,10 @@ const { t } = getI18n(Astro);
const approvedComments = await loadThread(descriptor.key, false);
renderThread(descriptor.key, approvedComments);
setStatus(t('paragraphComments.submitSuccess'), 'success');
void loadCaptcha(false);
} catch (error) {
setStatus(t('paragraphComments.submitFailed', { message: error instanceof Error ? error.message : t('common.unknownError') }), 'error');
void loadCaptcha(false);
}
});
@@ -798,6 +873,7 @@ const { t } = getI18n(Astro);
updateMarkerState();
applyMarkerVisibility(markersVisible, { persist: false });
await loadCaptcha(false);
await openFromHash();
window.addEventListener('hashchange', () => {
void openFromHash();

View File

@@ -1,6 +1,7 @@
---
import type { Post } from '../lib/types';
import CodeBlock from './CodeBlock.astro';
import ResponsiveImage from './ui/ResponsiveImage.astro';
import { formatReadTime, getI18n } from '../lib/i18n';
import {
getAccentVars,
@@ -55,9 +56,6 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
style={`--post-border-color: ${typeColor}`}
data-post-card-link
data-post-url={`/articles/${post.slug}`}
tabindex="0"
role="link"
aria-label={`Open ${post.title}`}
>
<div class="absolute left-0 top-4 bottom-4 w-1 rounded-full opacity-80" style={`background-color: ${typeColor}`}></div>
@@ -72,7 +70,7 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
href={`/articles/${post.slug}`}
class={`inline-flex min-w-0 items-center text-[var(--title-color)] transition hover:text-[var(--primary)] ${post.type === 'article' ? 'text-lg font-bold' : 'text-base font-bold'}`}
>
<h3 class="truncate" set:html={highlightText(post.title, highlightTerm)} />
<h2 class="truncate" set:html={highlightText(post.title, highlightTerm)} />
</a>
</div>
<p class="text-sm text-[var(--text-secondary)]">
@@ -105,11 +103,15 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
post.images && post.images.length === 1 ? 'aspect-video' :
'aspect-square'
]}>
<img
<ResponsiveImage
src={resolveFileRef(img)}
alt={`${post.title} - ${index + 1}`}
loading="lazy"
class="w-full h-full object-cover hover:scale-105 transition-transform"
pictureClass="block h-full w-full"
imgClass="w-full h-full object-cover hover:scale-105 transition-transform"
widths={post.images && post.images.length === 1 ? [640, 960, 1280, 1600] : [320, 480, 720, 960]}
sizes={post.images && post.images.length === 1
? '(min-width: 1024px) 40rem, 100vw'
: '(min-width: 1024px) 18rem, (min-width: 640px) 45vw, 100vw'}
/>
</div>
))}
@@ -153,13 +155,8 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
return Boolean(selection && selection.toString().trim());
};
const navigateFromCard = (card) => {
const href = card.dataset.postUrl;
if (!href) return;
window.location.href = href;
};
document.querySelectorAll('[data-post-card-link]').forEach((card) => {
if (!(card instanceof HTMLElement)) return;
if (card.dataset.postCardBound === 'true') return;
card.dataset.postCardBound = 'true';
@@ -167,15 +164,11 @@ const normalizedSelectedTag = selectedTag.trim().toLowerCase();
if (event.defaultPrevented) return;
if (hasTextSelection()) return;
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
navigateFromCard(card);
const href = card.dataset.postUrl;
if (!href) return;
window.location.href = href;
});
card.addEventListener('keydown', (event) => {
if (event.defaultPrevented) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
if (event.target instanceof Element && event.target.closest(interactiveSelector)) return;
event.preventDefault();
navigateFromCard(card);
});
});
</script>

View File

@@ -13,7 +13,7 @@ const { stats } = Astro.props;
<li class="rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(255,255,255,0.55))] px-4 py-4 shadow-[0_12px_32px_rgba(37,99,235,0.08)]">
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-3">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-white/75 text-[var(--primary)] shadow-sm">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-transparent bg-[var(--primary)] text-[var(--terminal-bg)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.22)]">
<span class="font-mono text-xs">{String(index + 1).padStart(2, '0')}</span>
</span>
<div class="min-w-0">

View File

@@ -0,0 +1,145 @@
---
import { resolvePublicApiBaseUrl } from '../lib/api/client';
interface Props {
requestUrl?: string | URL;
}
const { requestUrl } = Astro.props as Props;
const subscribeApiUrl = `${resolvePublicApiBaseUrl(requestUrl)}/subscriptions`;
---
<section class="terminal-subscribe-card" data-subscribe-root data-api-url={subscribeApiUrl}>
<div class="terminal-subscribe-head">
<p class="terminal-subscribe-kicker">newsletter / notifications</p>
<h3>订阅更新</h3>
<p>输入邮箱后,可以收到新文章通知;提交后需要先去邮箱点击确认链接才会正式生效。</p>
</div>
<form class="terminal-subscribe-form" data-subscribe-form>
<input type="text" name="displayName" placeholder="称呼(可选)" autocomplete="name" />
<input type="email" name="email" placeholder="name@example.com" autocomplete="email" required />
<button type="submit">订阅</button>
</form>
<p class="terminal-subscribe-status" data-subscribe-status>支持确认订阅、退订链接和偏好管理页。</p>
</section>
<script>
document.querySelectorAll('[data-subscribe-root]').forEach((root) => {
const form = root.querySelector('[data-subscribe-form]');
const status = root.querySelector('[data-subscribe-status]');
const apiUrl = root.getAttribute('data-api-url');
if (!(form instanceof HTMLFormElement) || !(status instanceof HTMLElement) || !apiUrl) {
return;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const email = String(formData.get('email') || '').trim();
const displayName = String(formData.get('displayName') || '').trim();
if (!email) {
status.textContent = '请输入邮箱地址。';
return;
}
status.textContent = '提交中...';
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
displayName,
source: 'frontend-home',
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.message || payload?.description || '订阅失败,请稍后再试。');
}
form.reset();
status.textContent =
payload?.message || '订阅申请已提交,请前往邮箱确认后生效。';
} catch (error) {
status.textContent = error instanceof Error ? error.message : '订阅失败,请稍后重试。';
}
});
});
</script>
<style>
.terminal-subscribe-card {
margin-top: 1.5rem;
border: 1px solid rgba(94, 234, 212, 0.16);
background: linear-gradient(135deg, rgba(15, 23, 42, 0.86), rgba(15, 23, 42, 0.72));
border-radius: 1rem;
padding: 1.1rem;
}
.terminal-subscribe-kicker {
margin: 0 0 0.35rem;
color: var(--primary);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.22em;
}
.terminal-subscribe-head h3 {
margin: 0;
font-size: 1.1rem;
}
.terminal-subscribe-head p:last-child {
margin: 0.45rem 0 0;
color: var(--text-secondary);
font-size: 0.92rem;
line-height: 1.7;
}
.terminal-subscribe-form {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
}
.terminal-subscribe-form input {
width: 100%;
border-radius: 0.8rem;
border: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.45);
color: var(--text-primary);
padding: 0.85rem 0.95rem;
}
.terminal-subscribe-form button {
border: 0;
border-radius: 0.8rem;
padding: 0.9rem 1rem;
font-weight: 600;
color: #08111f;
background: linear-gradient(135deg, var(--primary), #8b5cf6);
cursor: pointer;
}
.terminal-subscribe-status {
margin: 0.75rem 0 0;
color: var(--text-secondary);
font-size: 0.88rem;
}
@media (min-width: 768px) {
.terminal-subscribe-form {
grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1.2fr) auto;
align-items: center;
}
}
</style>

View File

@@ -10,7 +10,7 @@ const { items } = Astro.props;
<ul class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{items.map((item) => (
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(var(--primary-rgb),0.08))] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
<li class="group overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_12px_30px_rgba(37,99,235,0.08)] transition-transform duration-200 hover:-translate-y-0.5">
<div class="flex items-start gap-3 px-4 py-4">
<span class="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)] text-white shadow-[0_10px_24px_rgba(37,99,235,0.24)]">
<i class="fas fa-code text-xs"></i>

View File

@@ -1,17 +1,20 @@
<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;
};
function handleScroll() {
showButton = (window.scrollY || document.documentElement.scrollTop) > scrollThreshold;
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
onMount(() => {
handleScroll();
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
});
function scrollToTop() {
@@ -22,17 +25,12 @@
}
</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}
<button
class={`fixed bottom-8 right-8 z-50 flex h-12 w-12 items-center justify-center rounded-full border border-[var(--border-color)] bg-[var(--header-bg)] text-[var(--text-secondary)] shadow-lg transition-all hover:border-[var(--primary)] hover:text-[var(--primary)] ${
showButton ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-4 opacity-0'
}`}
aria-label="Back to top"
on:click={scrollToTop}
>
<i class="fas fa-chevron-up"></i>
</button>

View File

@@ -1,60 +1,106 @@
<script lang="ts">
import { onMount } from 'svelte';
let isDark = false;
type ThemeMode = 'light' | 'dark' | 'system';
type ResolvedTheme = 'light' | 'dark';
interface ThemeChangeDetail {
mode: ThemeMode;
resolved: ResolvedTheme;
}
interface ThemeLabels {
toggle: string;
system: string;
light: string;
dark: string;
}
interface ThemeApi {
getMode(): ThemeMode;
resolveTheme(mode: ThemeMode): ResolvedTheme;
applyTheme(mode: ThemeMode): ThemeChangeDetail;
syncTheme(): ThemeChangeDetail;
}
declare global {
interface Window {
__termiTheme?: ThemeApi;
}
}
export let labels: ThemeLabels;
let mode: ThemeMode = 'system';
let resolved: ResolvedTheme = 'light';
const themeMeta: Record<ThemeMode, { iconClass: string; color: string }> = {
light: {
iconClass: 'fas fa-sun text-sm',
color: 'var(--secondary)',
},
dark: {
iconClass: 'fas fa-moon text-sm',
color: 'var(--primary)',
},
system: {
iconClass: 'fas fa-desktop text-sm',
color: 'var(--text-secondary)',
},
};
function syncTheme(detail?: ThemeChangeDetail) {
const themeApi = window.__termiTheme;
if (!themeApi) {
return;
}
const nextState = detail ?? themeApi.syncTheme();
mode = nextState.mode;
resolved = nextState.resolved;
}
function cycleTheme() {
const themeApi = window.__termiTheme;
if (!themeApi) {
return;
}
const currentMode = themeApi.getMode();
const nextMode: ThemeMode =
currentMode === 'system' ? 'light' : currentMode === 'light' ? 'dark' : 'system';
syncTheme(themeApi.applyTheme(nextMode));
}
onMount(() => {
console.log('[ThemeToggle] onMount');
// Check for saved theme preference or system preference
const savedTheme = localStorage.getItem('theme');
console.log('[ThemeToggle] savedTheme:', savedTheme);
syncTheme();
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();
}
const handleThemeChange = (event: Event) => {
syncTheme((event as CustomEvent<ThemeChangeDetail>).detail);
};
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
window.addEventListener('termi:theme-change', handleThemeChange);
return () => {
window.removeEventListener('termi:theme-change', handleThemeChange);
};
});
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();
}
$: modeLabel =
mode === 'light' ? labels.light : mode === 'dark' ? labels.dark : labels.system;
$: resolvedLabel = resolved === 'dark' ? labels.dark : labels.light;
$: iconClass = themeMeta[mode].iconClass;
$: iconColor = themeMeta[mode].color;
$: toggleTitle = `${labels.toggle} · ${modeLabel} / ${resolvedLabel}`;
</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 ? '切换到亮色模式' : '切换到暗色模式'}
class="theme-toggle terminal-toolbar-iconbtn h-8 w-8 shrink-0"
aria-label={toggleTitle}
title={toggleTitle}
on:click|preventDefault={cycleTheme}
>
{#if isDark}
<i class="fas fa-sun text-[var(--secondary)]"></i>
{:else}
<i class="fas fa-moon text-[var(--primary)]"></i>
{/if}
<i class={iconClass} style:color={iconColor}></i>
<span class="sr-only">{modeLabel} / {resolvedLabel}</span>
</button>

View File

@@ -25,7 +25,7 @@ const uniqueId = Math.random().toString(36).slice(2, 11);
<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="command-text ml-2" id={`cmd-${uniqueId}`}>{command}</span>
<span class="cursor" id={`cursor-${uniqueId}`}>_</span>
</a>
) : (
@@ -34,76 +34,12 @@ const uniqueId = Math.random().toString(36).slice(2, 11);
<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="command-text ml-2" id={`cmd-${uniqueId}`}>{command}</span>
<span class="cursor" id={`cursor-${uniqueId}`}>_</span>
</>
)}
</div>
<script is:inline>
(function() {
function renderPrompt(el, nextCommand, typingMode) {
const id = el.getAttribute('data-id');
const cmdEl = document.getElementById('cmd-' + id);
const cursorEl = document.getElementById('cursor-' + id);
if (!cmdEl || !cursorEl) return;
const command = String(nextCommand || '');
const typing = typingMode === true || typingMode === 'true';
const renderSeq = String((Number(el.getAttribute('data-render-seq') || '0') || 0) + 1);
el.setAttribute('data-command', command);
el.setAttribute('data-render-seq', renderSeq);
cmdEl.textContent = '';
cursorEl.style.animation = 'none';
cursorEl.style.opacity = '1';
if (!typing) {
cmdEl.textContent = command;
cursorEl.style.animation = 'blink 1s infinite';
return;
}
let index = 0;
function typeChar() {
if (el.getAttribute('data-render-seq') !== renderSeq) {
return;
}
if (index < command.length) {
cmdEl.textContent += command.charAt(index);
index += 1;
setTimeout(typeChar, 42 + Math.random() * 22);
} else {
cursorEl.style.animation = 'blink 1s infinite';
}
}
setTimeout(typeChar, 120);
}
if (!window.__termiCommandPrompt) {
window.__termiCommandPrompt = {
set(promptId, command, options = {}) {
if (!promptId) return;
const el = document.querySelector(`[data-prompt-id="${promptId}"]`);
if (!el) return;
renderPrompt(el, command, options.typing ?? true);
}
};
}
const prompts = document.querySelectorAll('[data-command]:not([data-command-mounted])');
prompts.forEach(function(el) {
el.setAttribute('data-command-mounted', 'true');
renderPrompt(el, el.getAttribute('data-command') || '', el.getAttribute('data-typing') === 'true');
});
})();
</script>
<style>
.command-prompt {
display: flex;

View File

@@ -0,0 +1,92 @@
---
import {
buildOptimizedImageUrl,
buildOptimizedSrcSet,
canOptimizeImageSource,
getFallbackImageFormat,
getResponsiveWidths,
} from '../../lib/image';
interface Props {
src: string;
alt: string;
widths?: number[];
sizes?: string;
pictureClass?: string;
imgClass?: string;
loading?: 'lazy' | 'eager';
decoding?: 'async' | 'sync' | 'auto';
fetchpriority?: 'high' | 'low' | 'auto';
quality?: number;
lightbox?: boolean;
}
const {
src,
alt,
widths = [480, 768, 1024, 1440, 1920],
sizes = '100vw',
pictureClass = '',
imgClass = '',
loading = 'lazy',
decoding = 'async',
fetchpriority = 'auto',
quality = 72,
lightbox = false,
} = Astro.props;
const resolvedSrc = String(src || '').trim();
const normalizedWidths = getResponsiveWidths(widths);
const allowedHosts =
(import.meta.env.PUBLIC_IMAGE_ALLOWED_HOSTS as string | undefined) ||
process.env.PUBLIC_IMAGE_ALLOWED_HOSTS ||
'';
const optimize =
Boolean(resolvedSrc) &&
canOptimizeImageSource(resolvedSrc, Astro.url.origin, allowedHosts);
const fallbackFormat = getFallbackImageFormat(resolvedSrc);
const fallbackWidth = normalizedWidths[normalizedWidths.length - 1] ?? 1440;
const dataLightboxImage = lightbox ? 'true' : undefined;
---
{resolvedSrc ? (
optimize ? (
<picture class={pictureClass}>
<source
type="image/avif"
srcset={buildOptimizedSrcSet(resolvedSrc, normalizedWidths, 'avif', quality)}
sizes={sizes}
/>
<source
type="image/webp"
srcset={buildOptimizedSrcSet(resolvedSrc, normalizedWidths, 'webp', quality)}
sizes={sizes}
/>
<img
src={buildOptimizedImageUrl(resolvedSrc, {
width: fallbackWidth,
format: fallbackFormat,
quality,
})}
srcset={buildOptimizedSrcSet(resolvedSrc, normalizedWidths, fallbackFormat, quality)}
sizes={sizes}
alt={alt}
loading={loading}
decoding={decoding}
fetchpriority={fetchpriority}
data-lightbox-image={dataLightboxImage}
class={imgClass}
/>
</picture>
) : (
<img
src={resolvedSrc}
alt={alt}
loading={loading}
decoding={decoding}
fetchpriority={fetchpriority}
data-lightbox-image={dataLightboxImage}
class={imgClass}
/>
)
) : null}