feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
145
frontend/src/components/SubscriptionSignup.astro
Normal file
145
frontend/src/components/SubscriptionSignup.astro
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
92
frontend/src/components/ui/ResponsiveImage.astro
Normal file
92
frontend/src/components/ui/ResponsiveImage.astro
Normal 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}
|
||||
Reference in New Issue
Block a user