feat: update tag and timeline share panel copy for clarity and conciseness
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m4s
docker-images / build-and-push (admin) (push) Successful in 1m17s
docker-images / build-and-push (backend) (push) Successful in 28m13s
docker-images / build-and-push (frontend) (push) Successful in 47s
docker-images / submit-indexnow (push) Successful in 13s

style: enhance global CSS for better responsiveness of terminal chips and navigation pills

test: remove inline subscription test and add maintenance mode access code test

feat: implement media library picker dialog for selecting images from the media library

feat: add media URL controls for uploading and managing media assets

feat: add migration for music_enabled and maintenance_mode settings in site settings

feat: implement maintenance mode functionality with access control

feat: create maintenance page with access code input and error handling

chore: add TypeScript declaration for QR code module
This commit is contained in:
2026-04-02 23:05:49 +08:00
parent 6a50dd478c
commit 9665c933b5
94 changed files with 5266 additions and 1612 deletions

View File

@@ -16,7 +16,8 @@ const {
const { locale, t, buildLocaleUrl } = getI18n(Astro);
const aiEnabled = Boolean(Astro.props.siteSettings?.ai?.enabled);
const musicPlaylist = (Astro.props.siteSettings?.musicPlaylist || []).filter(
const musicEnabled = Astro.props.siteSettings?.musicEnabled ?? true;
const musicPlaylist = (musicEnabled ? Astro.props.siteSettings?.musicPlaylist : []).filter(
(item) => item?.title?.trim() && item?.url?.trim()
);
const musicPlaylistPayload = JSON.stringify(musicPlaylist);
@@ -60,11 +61,11 @@ const currentNavLabel =
</span>
</a>
<div class="relative hidden lg:block flex-1 min-w-0 max-w-[16rem] xl:max-w-[18rem]">
<div class="terminal-toolbar-module gap-2 px-2.5 py-1.5">
<div class="terminal-toolbar-label" id="search-label">{t('header.searchPromptKeyword')}</div>
<div class="relative hidden min-w-[20rem] grow basis-[24rem] lg:block xl:min-w-[24rem] xl:basis-[30rem]">
<div class="terminal-toolbar-module min-w-0 gap-2 px-2.5 py-1.5">
<div class="terminal-toolbar-label shrink-0 whitespace-nowrap" id="search-label">{t('header.searchPromptKeyword')}</div>
{aiEnabled && (
<div id="search-mode-panel" class="hidden 2xl:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
<div id="search-mode-panel" class="hidden shrink-0 2xl:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-1">
<button
type="button"
class="search-mode-btn rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--text-secondary)] transition hover:bg-[var(--header-bg)]"
@@ -91,7 +92,7 @@ const currentNavLabel =
placeholder={t('header.searchPlaceholderKeyword')}
class="terminal-console-input"
/>
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8" aria-label="Search">
<button id="search-btn" class="terminal-toolbar-iconbtn h-8 w-8 shrink-0" aria-label="Search">
<i id="search-btn-icon" class="fas fa-search text-sm"></i>
</button>
</div>
@@ -104,73 +105,89 @@ const currentNavLabel =
></div>
</div>
<div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5">
<div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8">
<img
id="desktop-music-cover"
src={currentMusicTrack?.coverImageUrl || ''}
alt={currentMusicTrack?.title || 'Music cover'}
class:list={[
'h-full w-full object-cover',
!currentMusicTrack?.coverImageUrl && 'hidden'
]}
/>
<i
id="desktop-music-cover-fallback"
class:list={[
'fas fa-compact-disc text-sm text-[var(--primary)]',
currentMusicTrack?.coverImageUrl && 'hidden'
]}
></i>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title">
{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" 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));" 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" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-[11px]"></i>
</button>
<div class="ml-auto hidden shrink-0 items-center gap-2 lg:flex">
{musicEnabled && (
<div class="hidden 2xl:flex terminal-toolbar-module min-w-0 max-w-[13rem] gap-2 px-2.5 py-1.5">
<div class="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-color)] bg-[var(--primary)]/8">
<img
id="desktop-music-cover"
src={currentMusicTrack?.coverImageUrl || ''}
alt={currentMusicTrack?.title || 'Music cover'}
class:list={[
'h-full w-full object-cover',
!currentMusicTrack?.coverImageUrl && 'hidden'
]}
/>
<i
id="desktop-music-cover-fallback"
class:list={[
'fas fa-compact-disc text-sm text-[var(--primary)]',
currentMusicTrack?.coverImageUrl && 'hidden'
]}
></i>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-[13px] font-semibold text-[var(--title-color)]" id="desktop-music-title">
{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" 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));" 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" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-[11px]"></i>
</button>
</div>
</div>
</div>
)}
{aiEnabled && (
<a
href="/ask"
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
>
<i class="fas fa-robot text-sm"></i>
<span class="hidden xl:inline">{t('nav.ask')}</span>
</a>
)}
<div class="flex shrink-0 items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
{localeLinks.map((item) => (
<a
href={item.href}
data-locale-switch={item.locale}
class:list={[
'rounded-lg px-2.5 py-1.5 text-xs font-semibold transition',
item.locale === locale
? 'bg-[var(--primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
]}
aria-current={item.locale === locale ? 'true' : undefined}
title={item.label}
>
{item.shortLabel}
</a>
))}
</div>
<div class="relative shrink-0">
<ThemeToggle
client:load
labels={{
toggle: t('header.themeToggle'),
system: t('header.themeSystem'),
light: t('header.themeLight'),
dark: t('header.themeDark'),
}}
/>
</div>
</div>
{aiEnabled && (
<a
href="/ask"
class="hidden lg:inline-flex items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
>
<i class="fas fa-robot text-sm"></i>
<span class="hidden xl:inline">{t('nav.ask')}</span>
</a>
)}
<div class="hidden lg:flex items-center gap-1 rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/80 p-0.5">
{localeLinks.map((item) => (
<a
href={item.href}
data-locale-switch={item.locale}
class:list={[
'rounded-lg px-2.5 py-1.5 text-xs font-semibold transition',
item.locale === locale
? 'bg-[var(--primary)] text-white shadow-sm'
: 'text-[var(--text-secondary)] hover:bg-[var(--header-bg)]'
]}
aria-current={item.locale === locale ? 'true' : undefined}
title={item.label}
>
{item.shortLabel}
</a>
))}
</div>
<div class="relative shrink-0">
<div class="relative shrink-0 lg:hidden">
<ThemeToggle
client:load
labels={{
@@ -262,51 +279,53 @@ const currentNavLabel =
</div>
</div>
<div class="terminal-toolbar-module items-center gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8">
<img
id="music-cover"
src={currentMusicTrack?.coverImageUrl || ''}
alt={currentMusicTrack?.title || 'Music cover'}
class:list={[
'h-full w-full object-cover',
!currentMusicTrack?.coverImageUrl && 'hidden'
]}
/>
<i
id="music-cover-fallback"
class:list={[
'fas fa-compact-disc text-base text-[var(--primary)]',
currentMusicTrack?.coverImageUrl && 'hidden'
]}
></i>
</div>
<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" 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));" 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" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-xs"></i>
</button>
<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>
{musicEnabled && (
<div class="terminal-toolbar-module items-center gap-3">
<div class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/8">
<img
id="music-cover"
src={currentMusicTrack?.coverImageUrl || ''}
alt={currentMusicTrack?.title || 'Music cover'}
class:list={[
'h-full w-full object-cover',
!currentMusicTrack?.coverImageUrl && 'hidden'
]}
/>
<i
id="music-cover-fallback"
class:list={[
'fas fa-compact-disc text-base text-[var(--primary)]',
currentMusicTrack?.coverImageUrl && 'hidden'
]}
></i>
</div>
<div class="mt-2 min-w-0">
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title">
{currentMusicTrack?.title || '未配置曲目'}
</p>
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist">
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'}
</p>
<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" 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));" 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" aria-label="Next track" disabled={!hasMusicPlaylist}>
<i class="fas fa-step-forward text-xs"></i>
</button>
<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>
<div class="mt-2 min-w-0">
<p class="truncate text-sm font-semibold text-[var(--title-color)]" id="music-title">
{currentMusicTrack?.title || '未配置曲目'}
</p>
<p class="truncate text-[11px] text-[var(--text-tertiary)]" id="music-artist">
{currentMusicTrack?.artist || currentMusicTrack?.album || '等待播放'}
</p>
</div>
</div>
</div>
</div>
)}
</div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
@@ -320,13 +339,13 @@ const currentNavLabel =
: ''
]}
>
<span class="flex items-center gap-3">
<span class="flex min-w-0 flex-1 items-center gap-3">
<span class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--header-bg)]/82 text-[var(--primary)]">
<i class={`fas ${item.icon} text-sm`}></i>
</span>
<span class="min-w-0">
<span class="terminal-toolbar-label block">{t('header.navigation')}</span>
<span class="mt-1 block text-sm font-semibold text-[var(--title-color)]">{item.text}</span>
<span class="mt-1 block truncate text-sm font-semibold text-[var(--title-color)]">{item.text}</span>
</span>
</span>
<i class="fas fa-arrow-right text-[11px] text-[var(--text-tertiary)]"></i>

File diff suppressed because it is too large Load Diff

View File

@@ -1,145 +0,0 @@
---
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

@@ -3,31 +3,40 @@
import { getI18n } from '../lib/i18n';
const { t } = getI18n(Astro);
const hasBeforeNav = Astro.slots.has('before-nav');
---
<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>
<aside
id="toc-container"
class="hidden w-full shrink-0 lg:block lg:w-72"
data-has-before-nav={hasBeforeNav ? 'true' : 'false'}
>
<div class="sticky top-24 space-y-4">
<slot name="before-nav" />
<div id="toc-panel" class="terminal-panel-muted space-y-4">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-terminal"></i>
nav stack
</span>
<div>
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3>
<p class="text-xs leading-6 text-[var(--text-secondary)]">
{t('toc.intro')}
</p>
<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)]">{t('toc.title')}</h3>
<p class="text-xs leading-6 text-[var(--text-secondary)]">
{t('toc.intro')}
</p>
</div>
</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>
<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>
</div>
</aside>
@@ -39,10 +48,13 @@ const { t } = getI18n(Astro);
const headings = content.querySelectorAll('h2, h3');
const tocNav = document.getElementById('toc-nav');
const tocPanel = document.getElementById('toc-panel');
const container = document.getElementById('toc-container');
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
if (!tocNav || headings.length === 0) {
const container = document.getElementById('toc-container');
if (container) container.style.display = 'none';
if (tocPanel) tocPanel.style.display = 'none';
if (container && !hasBeforeNav) container.style.display = 'none';
return;
}

View File

@@ -27,60 +27,38 @@ const { locale } = getI18n(Astro);
const isEnglish = locale.startsWith('en');
---
<section class="rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.94),rgba(var(--primary-rgb),0.04))] p-5 sm:p-6">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--primary)]">
<i class="fas fa-brain text-[10px]"></i>
{badge}
</span>
<span class="terminal-kicker">
<i class="fas fa-sitemap"></i>
{kicker}
</span>
<section class="sr-only" data-discovery-brief>
<p>{badge}</p>
<p>{kicker}</p>
<h3>{title}</h3>
<p>{summary}</p>
<div>
<h4>{isEnglish ? 'Key signals' : '关键信号'}</h4>
{highlights.length > 0 ? (
<ul>
{highlights.map((item) => (
<li>{item}</li>
))}
</ul>
) : (
<p>{summary}</p>
)}
</div>
<div class="mt-4">
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3>
<p class="mt-3 max-w-4xl text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
</div>
<div class="mt-5 grid gap-4 lg:grid-cols-2">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
{isEnglish ? 'Key signals' : '关键信号'}
<div>
<h4>{isEnglish ? 'FAQ' : '常见问答'}</h4>
{faqs.length > 0 ? (
<div>
{faqs.slice(0, 3).map((item) => (
<article>
<p>{item.question}</p>
<p>{item.answer}</p>
</article>
))}
</div>
{highlights.length > 0 ? (
<ul class="mt-3 space-y-3">
{highlights.map((item, index) => (
<li class="flex items-start gap-3 text-sm leading-7 text-[var(--text-secondary)]">
<span class="mt-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-xs font-semibold text-[var(--primary)]">
{index + 1}
</span>
<span>{item}</span>
</li>
))}
</ul>
) : (
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
)}
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/84 p-4">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">
{isEnglish ? 'FAQ' : '常见问答'}
</div>
{faqs.length > 0 ? (
<div class="mt-3 space-y-3">
{faqs.slice(0, 3).map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/65 bg-[var(--bg)]/60 px-4 py-3">
<p class="text-sm font-semibold leading-6 text-[var(--title-color)]">{item.question}</p>
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{item.answer}</p>
</div>
))}
</div>
) : (
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{summary}</p>
)}
</div>
) : (
<p>{summary}</p>
)}
</div>
</section>

View File

@@ -26,20 +26,23 @@ const {
shareTitle,
summary,
canonicalUrl,
badge = isEnglish ? 'distribution' : '快速分发',
kicker = 'geo / share',
title = isEnglish ? 'Share & AI discovery' : '分享与 AI 分发',
badge = isEnglish ? 'page share' : '页面分享',
title = isEnglish ? 'Quick share' : '一键分享',
description = isEnglish
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
: '让规范链接持续通过社交渠道回流,方便用户传播,也方便 AI 搜索把信号聚合到同一个来源。',
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
stats = [],
wechatShareQrEnabled = false,
} = Astro.props as Props;
const visibleBadge = badge;
const visibleTitle = title;
const visibleDescription = description;
const copy = isEnglish
? {
summaryTitle: 'Share note',
canonical: 'Canonical',
summaryTitle: 'Page summary',
canonical: 'Page link',
copySummary: 'Copy note',
copySummarySuccess: 'Share note copied',
copySummaryFailed: 'Copy failed',
@@ -54,8 +57,8 @@ const copy = isEnglish
shareToTelegram: 'Share to Telegram',
shareToWeChat: 'WeChat QR',
qrModalTitle: 'WeChat scan share',
qrModalDescription: 'Scan this local QR code in WeChat to open the canonical URL on mobile.',
qrModalHint: 'Keep the canonical link as the single source of truth for social sharing and AI discovery.',
qrModalDescription: 'Scan this QR code in WeChat to open the current page on mobile.',
qrModalHint: 'Sharing the page link is enough for others to continue from here.',
downloadQr: 'Download QR',
downloadQrStarted: 'QR download started',
qrOpened: 'WeChat QR ready',
@@ -64,30 +67,30 @@ const copy = isEnglish
toastInfoTitle: 'Share ready',
}
: {
summaryTitle: '分享摘要',
canonical: '规范地址',
copySummary: '复制摘要',
copySummarySuccess: '分享摘要已复制',
summaryTitle: '页面简介',
canonical: '固定链接',
copySummary: '复制简介',
copySummarySuccess: '页面简介已复制',
copySummaryFailed: '复制失败',
copyLink: '复制固定链接',
copyLinkSuccess: '固定链接已复制',
copyLinkFailed: '固定链接复制失败',
shareSummary: '分享摘要',
shareSuccess: '已打开分享面板',
shareFallback: '分享文案已复制',
shareSummary: '直接分享',
shareSuccess: '已打开系统分享',
shareFallback: '分享内容已复制',
shareFailed: '分享失败',
shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫',
qrModalTitle: '微信扫码分享',
qrModalDescription: '使用本地生成的二维码,在微信扫一扫,就能直接打开当前页面的规范链接。',
qrModalHint: '尽量分享规范地址,方便用户回访,也方便 AI 搜索把信号聚合回同一个页面。',
shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫一扫',
qrModalDescription: '微信扫一扫,就能在手机上继续浏览当前页面。',
qrModalHint: '如果要发给别人,直接复制下方链接会更方便。',
downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开',
toastSuccessTitle: '操作完成',
toastErrorTitle: '操作失败',
toastInfoTitle: '分享渠道已就绪',
toastInfoTitle: '已准备好',
};
const safeSummary = summary.trim() || shareTitle;
@@ -112,7 +115,7 @@ if (wechatShareQrEnabled) {
wechatShareQrSvg = await QRCode.toString(canonicalUrl, {
type: 'svg',
margin: 1,
width: 220,
width: 240,
color: {
dark: '#111827',
light: '#ffffff',
@@ -120,7 +123,7 @@ if (wechatShareQrEnabled) {
});
wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, {
margin: 1,
width: 360,
width: 420,
color: {
dark: '#111827',
light: '#ffffff',
@@ -141,17 +144,13 @@ if (wechatShareQrEnabled) {
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
<i class="fas fa-satellite-dish text-[10px]"></i>
{badge}
</span>
<span class="terminal-kicker">
<i class="fas fa-share-nodes"></i>
{kicker}
{visibleBadge}
</span>
</div>
<div class="space-y-2">
<h3 class="text-xl font-semibold text-[var(--title-color)]">{title}</h3>
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{description}</p>
<h3 class="text-xl font-semibold text-[var(--title-color)]">{visibleTitle}</h3>
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{visibleDescription}</p>
</div>
</div>
@@ -218,7 +217,7 @@ if (wechatShareQrEnabled) {
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share channels' : '分享渠道'}
</p>
<p class="text-xs leading-6 text-[var(--text-secondary)]">{description}</p>
<p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
</div>
<div class="flex flex-wrap gap-2">
<a
@@ -267,7 +266,7 @@ if (wechatShareQrEnabled) {
aria-hidden="true"
>
<div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-3xl rounded-[30px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.98),rgba(var(--bg-rgb),0.92))] p-5 shadow-[0_24px_80px_rgba(15,23,42,0.28)] sm:p-6">
<div class="w-full max-w-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<span class="terminal-kicker">
@@ -292,24 +291,25 @@ if (wechatShareQrEnabled) {
</button>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[240px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-4 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div>
<div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical}
</div>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 p-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle}
</div>
<p class="mt-2 text-sm font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
<p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{safeSummary}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p>
</div>

View File

@@ -17,6 +17,9 @@ declare global {
locale: string;
messages: Record<string, unknown>;
};
__termiCommentsReady?: boolean;
__termiHomeReady?: boolean;
__termiSubscriptionPopupReady?: boolean;
__termiTranslate: (
key: string,
params?: Record<string, string | number | null | undefined>

View File

@@ -270,6 +270,7 @@ export interface ApiSiteSettings {
social_email: string | null;
location: string | null;
tech_stack: string[] | null;
music_enabled?: boolean | null;
music_playlist: Array<{
title: string;
artist?: string | null;
@@ -423,10 +424,10 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
siteName: 'InitCool',
siteShortName: 'Termi',
siteUrl: 'https://init.cool',
siteTitle: 'InitCool - 终端风格的内容平台',
siteDescription: '一个基于终端美学的个人内容站,记录代码、设计和生活。',
heroTitle: '欢迎来到我的极客终端博客',
heroSubtitle: '这里记录技术、代码和生活点滴',
siteTitle: 'InitCool · 技术笔记与内容档案',
siteDescription: '围绕开发实践、产品观察与长期积累整理的中文内容站。',
heroTitle: '欢迎来到 InitCool',
heroSubtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。',
ownerName: 'InitCool',
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool',
ownerBio: 'InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。',
@@ -437,6 +438,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
email: 'mailto:initcoool@gmail.com',
},
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'],
musicEnabled: true,
musicPlaylist: [
{
title: '山中来信',
@@ -597,28 +599,8 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
settings.subscription_verification_mode,
settings.subscription_turnstile_enabled ? 'turnstile' : 'off',
);
return {
id: String(settings.id),
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle,
ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio,
ownerAvatarUrl: settings.owner_avatar_url ?? undefined,
location: settings.location || DEFAULT_SITE_SETTINGS.location,
social: {
github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github,
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
musicPlaylist:
const musicEnabled = settings.music_enabled ?? true;
const normalizedMusicPlaylist =
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length
? settings.music_playlist
.filter((item) => item.title.trim() && item.url.trim())
@@ -631,43 +613,66 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
accentColor: item.accent_color ?? undefined,
description: item.description ?? undefined,
}))
: DEFAULT_SITE_SETTINGS.musicPlaylist,
ai: {
enabled: Boolean(settings.ai_enabled),
},
comments: {
verificationMode: commentVerificationMode,
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
turnstileEnabled: commentVerificationMode === 'turnstile',
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
},
subscriptions: {
popupEnabled:
settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
popupTitle:
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
popupDescription:
settings.subscription_popup_description ||
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription,
popupDelaySeconds:
settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
verificationMode: subscriptionVerificationMode,
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
webPushEnabled: Boolean(settings.web_push_enabled),
webPushVapidPublicKey:
settings.web_push_vapid_public_key ||
resolvePublicWebPushVapidPublicKey() ||
undefined,
},
seo: {
defaultOgImage: settings.seo_default_og_image ?? undefined,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled),
},
: DEFAULT_SITE_SETTINGS.musicPlaylist;
return {
id: String(settings.id),
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
ownerTitle: settings.owner_title || DEFAULT_SITE_SETTINGS.ownerTitle,
ownerBio: settings.owner_bio || DEFAULT_SITE_SETTINGS.ownerBio,
ownerAvatarUrl: settings.owner_avatar_url ?? undefined,
location: settings.location || DEFAULT_SITE_SETTINGS.location,
social: {
github: settings.social_github || DEFAULT_SITE_SETTINGS.social.github,
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
},
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack,
musicEnabled,
musicPlaylist: musicEnabled ? normalizedMusicPlaylist : [],
ai: {
enabled: Boolean(settings.ai_enabled),
},
comments: {
verificationMode: commentVerificationMode,
paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
turnstileEnabled: commentVerificationMode === 'turnstile',
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
},
subscriptions: {
popupEnabled:
settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
popupTitle:
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
popupDescription:
settings.subscription_popup_description ||
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription,
popupDelaySeconds:
settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
verificationMode: subscriptionVerificationMode,
turnstileEnabled: subscriptionVerificationMode === 'turnstile',
turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined,
webPushEnabled: Boolean(settings.web_push_enabled),
webPushVapidPublicKey:
settings.web_push_vapid_public_key ||
resolvePublicWebPushVapidPublicKey() ||
undefined,
},
seo: {
defaultOgImage: settings.seo_default_og_image ?? undefined,
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled),
},
};
};

View File

@@ -84,7 +84,7 @@ export interface TerminalConfig {
export const terminalConfig: TerminalConfig = {
defaultCategory: 'blog',
welcomeMessage: '欢迎来到我的博客',
welcomeMessage: '欢迎来到 InitCool',
prompt: {
prefix: 'user@blog',
separator: ':',
@@ -100,8 +100,8 @@ I N NN I T C O O O O L
I N N I T CCCC OOO OOO LLLLL`,
title: '~/blog',
welcome: {
title: '欢迎来到我的极客终端博客',
subtitle: '这里记录技术、代码和生活点滴'
title: '欢迎来到 InitCool',
subtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。'
},
navLinks: [
{ icon: 'fa-file-code', text: '文章', href: '/articles' },

View File

@@ -0,0 +1,41 @@
export const MAINTENANCE_ACCESS_COOKIE_NAME = 'termi_maintenance_access'
export function sanitizeMaintenanceReturnTo(value: string | null | undefined): string {
if (!value) {
return '/'
}
const trimmed = value.trim()
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) {
return '/'
}
try {
const parsed = new URL(trimmed, 'https://termi.local')
const nextPath = `${parsed.pathname}${parsed.search}${parsed.hash}`
if (
nextPath === '/maintenance' ||
nextPath.startsWith('/maintenance?') ||
nextPath.startsWith('/api/maintenance')
) {
return '/'
}
return nextPath || '/'
} catch {
return '/'
}
}
export function shouldBypassMaintenance(pathname: string): boolean {
return (
pathname === '/maintenance' ||
pathname.startsWith('/api/maintenance') ||
pathname === '/healthz' ||
pathname === '/favicon.svg' ||
pathname.startsWith('/_astro/') ||
pathname.startsWith('/_image') ||
pathname.startsWith('/_img')
)
}

View File

@@ -84,6 +84,7 @@ export interface SiteSettings {
};
techStack: string[];
musicPlaylist: MusicTrack[];
musicEnabled: boolean;
ai: {
enabled: boolean;
};

View File

@@ -0,0 +1,55 @@
import { defineMiddleware } from 'astro:middleware'
import { resolveInternalApiBaseUrl } from './lib/api/client'
import {
MAINTENANCE_ACCESS_COOKIE_NAME,
sanitizeMaintenanceReturnTo,
shouldBypassMaintenance,
} from './lib/maintenance'
interface MaintenanceStatusResponse {
maintenance_mode_enabled?: boolean
access_granted?: boolean
}
async function fetchMaintenanceStatus(url: URL, accessToken?: string): Promise<MaintenanceStatusResponse> {
const response = await fetch(`${resolveInternalApiBaseUrl(url)}/site_settings/maintenance/status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
accessToken: accessToken?.trim() || undefined,
}),
})
if (!response.ok) {
throw new Error(`Maintenance status request failed: ${response.status}`)
}
return response.json() as Promise<MaintenanceStatusResponse>
}
export const onRequest = defineMiddleware(async (context, next) => {
const { url, cookies, redirect } = context
if (shouldBypassMaintenance(url.pathname)) {
return next()
}
try {
const accessToken = cookies.get(MAINTENANCE_ACCESS_COOKIE_NAME)?.value
const status = await fetchMaintenanceStatus(url, accessToken)
const maintenanceModeEnabled = Boolean(status.maintenance_mode_enabled)
const accessGranted = Boolean(status.access_granted)
if (!maintenanceModeEnabled || accessGranted) {
return next()
}
} catch (error) {
console.error('Failed to resolve maintenance mode status:', error)
}
const returnTo = sanitizeMaintenanceReturnTo(`${url.pathname}${url.search}`)
return redirect(`/maintenance?returnTo=${encodeURIComponent(returnTo)}`, 302)
})

View File

@@ -33,7 +33,6 @@ try {
{ label: t('common.posts'), value: String(posts.length) },
{ label: t('common.tags'), value: String(tags.length) },
{ label: t('common.friends'), value: String(friendLinks.filter(friend => friend.status === 'approved').length) },
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
];
} catch (error) {
console.error('Failed to load about data:', error);
@@ -42,7 +41,6 @@ try {
{ label: t('common.posts'), value: '0' },
{ label: t('common.tags'), value: '0' },
{ label: t('common.friends'), value: '0' },
{ label: t('common.location'), value: siteSettings.location || t('common.unknown') },
];
}
@@ -57,14 +55,13 @@ const sharePanelCopy = isEnglish
'Use this page as the canonical identity and capability profile so social sharing and AI search can cite one stable source.',
}
: {
badge: '身份主页',
title: '分享这张身份名片页',
description: '把这页当成统一的身份与能力来源分发出去,方便社交回流,也方便 AI 搜索引用到同一个规范地址。',
badge: '个人介绍',
title: '分享个人介绍',
description: '把这页作为个人介绍页分享,方便快速了解作者信息、技术栈和联系方式。',
};
const aboutHighlights = buildDiscoveryHighlights([
siteSettings.ownerTitle,
siteSettings.ownerBio,
siteSettings.location || '',
siteSettings.techStack.slice(0, 4).join(' / '),
]);
const aboutFaqs = buildPageFaqs({
@@ -154,10 +151,6 @@ const aboutJsonLd = [
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span class="terminal-stat-pill">
<i class="fas fa-location-dot text-[var(--primary)]"></i>
<span>{siteSettings.location || t('common.unknown')}</span>
</span>
<span class="terminal-stat-pill">
<i class="fas fa-layer-group text-[var(--primary)]"></i>
<span>{t('about.techStackCount', { count: techStack.length })}</span>

View File

@@ -0,0 +1,79 @@
import type { APIRoute } from 'astro'
import { resolveInternalApiBaseUrl } from '../../../lib/api/client'
import {
MAINTENANCE_ACCESS_COOKIE_NAME,
sanitizeMaintenanceReturnTo,
} from '../../../lib/maintenance'
interface MaintenanceVerifyResponse {
maintenance_mode_enabled?: boolean
access_granted?: boolean
access_token?: string | null
}
export const POST: APIRoute = async ({ request, url, cookies, redirect }) => {
const formData = await request.formData().catch(() => null)
const code = String(formData?.get('code') ?? '').trim()
const returnTo = sanitizeMaintenanceReturnTo(String(formData?.get('returnTo') ?? '/'))
if (!code) {
return redirect(`/maintenance?error=empty&returnTo=${encodeURIComponent(returnTo)}`, 302)
}
try {
const response = await fetch(`${resolveInternalApiBaseUrl(url)}/site_settings/maintenance/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
})
if (!response.ok) {
throw new Error(`Maintenance verify request failed: ${response.status}`)
}
const payload = (await response.json()) as MaintenanceVerifyResponse
const maintenanceModeEnabled = Boolean(payload.maintenance_mode_enabled)
const accessGranted = Boolean(payload.access_granted)
const accessToken = payload.access_token?.trim()
if (!maintenanceModeEnabled) {
cookies.set(MAINTENANCE_ACCESS_COOKIE_NAME, '', {
httpOnly: true,
sameSite: 'lax',
secure: url.protocol === 'https:',
path: '/',
maxAge: 0,
})
return redirect(returnTo, 302)
}
if (accessGranted && accessToken) {
cookies.set(MAINTENANCE_ACCESS_COOKIE_NAME, accessToken, {
httpOnly: true,
sameSite: 'lax',
secure: url.protocol === 'https:',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
return redirect(returnTo, 302)
}
cookies.set(MAINTENANCE_ACCESS_COOKIE_NAME, '', {
httpOnly: true,
sameSite: 'lax',
secure: url.protocol === 'https:',
path: '/',
maxAge: 0,
})
return redirect(`/maintenance?error=invalid&returnTo=${encodeURIComponent(returnTo)}`, 302)
} catch (error) {
console.error('Failed to unlock maintenance mode:', error)
return redirect(`/maintenance?error=unavailable&returnTo=${encodeURIComponent(returnTo)}`, 302)
}
}

View File

@@ -92,20 +92,20 @@ const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, '');
const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled;
const articleCopy = isEnglish
? {
digestBadge: 'featured digest',
digestKicker: 'ai digest',
digestTitle: 'AI / search summary',
digestBadge: 'quick brief',
digestKicker: 'reading preview',
digestTitle: 'Read this first',
digestDescription:
'This block exposes a compact summary, key takeaways, and canonical follow-up paths for AI search and human skimming.',
'A short overview of the article so readers can quickly grasp the key points before sharing or saving it.',
highlightsTitle: 'Key takeaways',
faqTitle: 'Quick FAQ',
sourceTitle: 'Canonical source signals',
sourceTitle: 'Page details',
readTime: 'Read time',
insightCount: 'Key points',
faqCount: 'FAQ',
updated: 'Updated',
category: 'Category',
canonical: 'Canonical',
canonical: 'Permalink',
keywords: 'Keywords',
copySummary: 'Copy digest',
copySuccess: 'Digest copied',
@@ -114,39 +114,39 @@ const articleCopy = isEnglish
shareSuccess: 'Share panel opened',
shareFallback: 'Share text copied',
shareFailed: 'Share failed',
shareChannelsTitle: 'Quick share',
shareChannelsTitle: 'Share options',
shareChannelsDescription:
'Push this article to social channels with a shorter path, so people and AI search tools can pick up the canonical link faster.',
'Copy the overview or permalink, or continue sharing through the channels below.',
shareToX: 'Share to X',
shareToTelegram: 'Share to Telegram',
shareToWeChat: 'WeChat QR',
qrModalTitle: 'WeChat scan share',
qrModalDescription: 'Scan this local QR code in WeChat to open the canonical article URL on mobile.',
qrModalHint: 'Prefer sharing the canonical link so users and AI engines can fold signals back to one source.',
shareToWeChat: 'WeChat scan',
qrModalTitle: 'Scan with WeChat',
qrModalDescription: 'Scan this QR code in WeChat to continue reading the article on mobile.',
qrModalHint: 'When you want to send the article to someone else, copying the permalink below is usually the easiest option.',
downloadQr: 'Download QR',
downloadQrStarted: 'QR download started',
qrOpened: 'WeChat QR ready',
floatingToolsTitle: 'Digest tools',
floatingToolsTitle: 'Quick actions',
copyPermalinkSuccess: 'Permalink copied',
copyPermalinkFailed: 'Permalink copy failed',
toastSuccessTitle: 'Done',
toastErrorTitle: 'Action failed',
toastInfoTitle: 'Share ready',
toastInfoTitle: 'Ready',
}
: {
digestBadge: '精选摘要',
digestKicker: 'ai digest',
digestTitle: 'AI / 搜索摘要',
digestDescription: '这块内容会把页面结论、重点摘录和规范入口显式写出来,方便 AI 搜索和用户快速理解。',
digestBadge: '文章导读',
digestKicker: '阅读前速览',
digestTitle: '先看重点',
digestDescription: '先用几句话帮你抓住这篇文章的重点,方便快速浏览、收藏或转发。',
highlightsTitle: '关键信息',
faqTitle: '快速问答',
sourceTitle: '规范来源信号',
sourceTitle: '页面信息',
readTime: '阅读时长',
insightCount: '重点条数',
faqCount: '问答条数',
updated: '最近更新',
category: '归档分类',
canonical: '规范地址',
canonical: '固定链接',
keywords: '关键词',
copySummary: '复制摘要',
copySuccess: '摘要已复制',
@@ -155,23 +155,23 @@ const articleCopy = isEnglish
shareSuccess: '已打开分享面板',
shareFallback: '分享文案已复制',
shareFailed: '分享失败',
shareChannelsTitle: '快速分发',
shareChannelsDescription: '用更短路径把这篇内容发到社交渠道,方便二次传播和 AI 引用回链。',
shareChannelsTitle: '分享方式',
shareChannelsDescription: '可以直接复制摘要、固定链接,或通过常用渠道继续转发。',
shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫',
qrModalTitle: '微信扫码分享',
qrModalDescription: '使用本地生成的二维码,在微信扫一扫,就能直接打开这篇文章的规范链接。',
qrModalHint: '尽量分享规范地址,方便用户回访,也方便 AI 搜索把信号聚合回同一篇内容。',
shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫一扫',
qrModalDescription: '微信扫一扫,就能在手机上继续阅读这篇文章。',
qrModalHint: '发给别人时,优先复制固定链接,对方打开会更方便。',
downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开',
floatingToolsTitle: '摘要工具',
floatingToolsTitle: '快捷操作',
copyPermalinkSuccess: '固定链接已复制',
copyPermalinkFailed: '固定链接复制失败',
toastSuccessTitle: '操作完成',
toastErrorTitle: '操作失败',
toastInfoTitle: '分享渠道已就绪',
toastInfoTitle: '已准备好',
};
const markdownProcessor = await createMarkdownProcessor();
@@ -236,7 +236,7 @@ if (wechatShareQrEnabled) {
wechatShareQrSvg = await QRCode.toString(canonicalUrl, {
type: 'svg',
margin: 1,
width: 220,
width: 240,
color: {
dark: '#111827',
light: '#ffffff',
@@ -244,7 +244,7 @@ if (wechatShareQrEnabled) {
});
wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, {
margin: 1,
width: 360,
width: 420,
color: {
dark: '#111827',
light: '#ffffff',
@@ -434,55 +434,7 @@ const breadcrumbJsonLd = {
<div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div>
<div class="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div>
<div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
<div class="pointer-events-none absolute right-4 top-4 z-10 hidden xl:block">
<div class="pointer-events-auto rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 px-2 py-2 shadow-[0_10px_28px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="mb-2 px-2 text-[10px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.floatingToolsTitle}
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-copy"
title={articleCopy.copySummary}
aria-label={articleCopy.copySummary}
>
<i class="fas fa-copy text-sm"></i>
</button>
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-share"
title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary}
>
<i class="fas fa-share-nodes text-sm"></i>
</button>
{wechatShareQrEnabled && wechatShareQrSvg && (
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="wechat-qr"
title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat}
>
<i class="fab fa-weixin text-sm"></i>
</button>
)}
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="permalink-copy"
title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')}
>
<i class="fas fa-link text-sm"></i>
</button>
</div>
</div>
</div>
<div class="relative grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(19rem,0.95fr)]">
<div class="relative grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(19rem,0.95fr)]">
<div class="space-y-5">
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
@@ -490,7 +442,7 @@ const breadcrumbJsonLd = {
{articleCopy.digestBadge}
</span>
<span class="terminal-kicker">
<i class="fas fa-robot"></i>
<i class="fas fa-book-open"></i>
{articleCopy.digestKicker}
</span>
</div>
@@ -591,25 +543,6 @@ const breadcrumbJsonLd = {
))}
</div>
{articleHighlights.length > 0 && (
<div class="space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.highlightsTitle}
</h3>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{articleHighlights.map((item, index) => (
<div class="rounded-2xl border border-[var(--border-color)]/80 bg-[var(--terminal-bg)]/80 p-4 shadow-[0_10px_30px_rgba(15,23,42,0.04)]">
<div class="flex items-start gap-3">
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)]/12 text-sm font-semibold text-[var(--primary)]">
{String(index + 1).padStart(2, '0')}
</span>
<p class="text-sm leading-7 text-[var(--text-secondary)]">{item}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div class="space-y-4">
@@ -867,7 +800,60 @@ const breadcrumbJsonLd = {
</section>
</div>
<TableOfContents />
<TableOfContents>
<div slot="before-nav" class="terminal-panel-muted space-y-3">
<span class="terminal-kicker">
<i class="fas fa-bolt"></i>
{articleCopy.floatingToolsTitle}
</span>
<div
class:list={[
'grid gap-2',
wechatShareQrEnabled && wechatShareQrSvg ? 'grid-cols-4' : 'grid-cols-3',
]}
>
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-copy"
title={articleCopy.copySummary}
aria-label={articleCopy.copySummary}
>
<i class="fas fa-copy text-sm"></i>
</button>
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-share"
title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary}
>
<i class="fas fa-share-nodes text-sm"></i>
</button>
{wechatShareQrEnabled && wechatShareQrSvg && (
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="wechat-qr"
title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat}
>
<i class="fab fa-weixin text-sm"></i>
</button>
)}
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="permalink-copy"
title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')}
>
<i class="fas fa-link text-sm"></i>
</button>
</div>
</div>
</TableOfContents>
</div>
</div>
@@ -878,7 +864,7 @@ const breadcrumbJsonLd = {
aria-hidden="true"
>
<div class="flex min-h-screen items-center justify-center p-4">
<div class="w-full max-w-3xl rounded-[30px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.98),rgba(var(--bg-rgb),0.92))] p-5 shadow-[0_24px_80px_rgba(15,23,42,0.28)] sm:p-6">
<div class="w-full max-w-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
<div class="flex items-start justify-between gap-4">
<div class="space-y-2">
<span class="terminal-kicker">
@@ -903,24 +889,25 @@ const breadcrumbJsonLd = {
</button>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[240px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[240px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-4 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div>
<div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.canonical}
</div>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 p-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.digestTitle}
</div>
<p class="mt-2 text-sm font-semibold leading-7 text-[var(--title-color)]">{post.title}</p>
<p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{post.title}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{articleSynopsis}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.qrModalHint}</p>
</div>

View File

@@ -117,9 +117,9 @@ const sharePanelCopy = isEnglish
page: 'Page',
}
: {
badge: '内容归档',
title: '分享文章总归档页',
description: '把文章归档页当成统一入口分发出去,方便 AI 检索和读者从一个规范地址继续按类型、分类和标签深入浏览。',
badge: '文章列表',
title: '分享文章列表',
description: '把文章列表分享出去,方便继续按分类、标签和类型浏览。',
posts: '文章数',
categories: '分类数',
tags: '标签数',

View File

@@ -73,9 +73,9 @@ const sharePanelCopy = isEnglish
ai: 'AI',
}
: {
badge: 'AI 检索',
title: '分享站内 AI 问答页',
description: '把这个 AI 问答入口作为基于问题的规范发现页分发出去,方便用户与 AI 都围绕站内稳定来源继续检索。',
badge: '问答入口',
title: '分享问答页',
description: '把这个问答页分享给需要快速检索站内内容的人。',
examples: '示例问题',
ai: 'AI',
};

View File

@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
slug: 'Slug',
}
: {
badge: '分类聚合',
title: '分享这个分类聚合页',
description: '这个分类页当成主题入口持续分发,方便用户快速理解,也方便 AI 搜索把同主题信号聚合回这里。',
badge: '分类',
title: '分享这个分类页',
description: '分享这个分类页,方便集中查看同主题内容。',
posts: '文章数',
slug: 'Slug',
};

View File

@@ -48,9 +48,9 @@ const sharePanelCopy = isEnglish
site: 'Site',
}
: {
badge: '分类目录',
title: '分享分类总览',
description: '把分类索引页作为全站主题地图分发出去,方便读者和 AI 搜索从一个规范入口继续下钻到对应专题。',
badge: '分类总览',
title: '分享分类总览',
description: '把分类总览分享出去,方便按主题快速找到内容。',
categories: '分类数',
site: '站点',
};

View File

@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
groups: 'Groups',
}
: {
badge: '友链网络',
badge: '友情链接',
title: '分享友情链接页',
description: '把友情链接页当成站点网络地图分发出去,方便 AI 搜索和读者理解这个站点的可信邻居与外部引用关系。',
description: '把友情链接页分享出去,方便查看常访问的网站与推荐来源。',
links: '友链数',
groups: '分组数',
};

View File

@@ -8,7 +8,6 @@ import CommandPrompt from '../components/ui/CommandPrompt.astro';
import FilterPill from '../components/ui/FilterPill.astro';
import PostCard from '../components/PostCard.astro';
import FriendLinkCard from '../components/FriendLinkCard.astro';
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
import StatsList from '../components/StatsList.astro';
import TechStackList from '../components/TechStackList.astro';
@@ -251,9 +250,9 @@ const homeShareCopy = isEnglish
'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
}
: {
badge: '站点入口',
title: '分享首页总入口',
description: '把首页当成站点的规范总入口分发出去,方便用户和 AI 搜索继续进入文章、分类、评测和个人介绍等核心页面。',
badge: '首页',
title: '分享首页',
description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。',
};
const homeBriefHighlights = buildDiscoveryHighlights([
siteSettings.siteDescription,
@@ -326,7 +325,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
{navLinks.map(link => (
<a href={link.href} class="home-nav-pill">
<i class={`fas ${link.icon} text-[11px]`}></i>
<span>{link.text}</span>
<span class="min-w-0 truncate">{link.text}</span>
</a>
))}
</div>
@@ -365,13 +364,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
)}
<div class="mb-8 px-4">
<CommandPrompt command="subscriptions create --channel email" />
<div class="ml-4">
<SubscriptionSignup requestUrl={Astro.request.url} />
</div>
</div>
<div id="discover" class="mb-6 px-4">
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
<div class="ml-4 terminal-panel home-discovery-shell">
@@ -414,25 +406,25 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
<div id="home-active-filters" class:list={['home-active-filter-row', !hasActiveFilters && 'hidden']}>
<span id="home-active-type" class:list={['terminal-chip', selectedType === 'all' && 'hidden']}>
<span id="home-active-type" class:list={['terminal-chip max-w-full min-w-0', selectedType === 'all' && 'hidden']}>
<i id="home-active-type-icon" class={`fas ${postTypeFilters.find((item) => item.id === selectedType)?.icon || 'fa-stream'} text-[10px]`}></i>
<span id="home-active-type-text">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span>
<span id="home-active-type-text" class="min-w-0 truncate">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span>
</span>
<span
id="home-active-category"
class:list={['terminal-chip terminal-chip--accent', !selectedCategory && 'hidden']}
class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedCategory && 'hidden']}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
>
<i class="fas fa-folder-open text-[10px]"></i>
<span id="home-active-category-text">{selectedCategory}</span>
<span id="home-active-category-text" class="min-w-0 truncate">{selectedCategory}</span>
</span>
<span
id="home-active-tag"
class:list={['terminal-chip terminal-chip--accent', !selectedTag && 'hidden']}
class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedTag && 'hidden']}
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
>
<i class="fas fa-hashtag text-[10px]"></i>
<span id="home-active-tag-text">{selectedTag}</span>
<span id="home-active-tag-text" class="min-w-0 truncate">{selectedTag}</span>
</span>
</div>

View File

@@ -0,0 +1,99 @@
---
import '../styles/global.css'
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client'
import { sanitizeMaintenanceReturnTo } from '../lib/maintenance'
const errorCode = Astro.url.searchParams.get('error')
const returnTo = sanitizeMaintenanceReturnTo(Astro.url.searchParams.get('returnTo'))
let siteSettings = DEFAULT_SITE_SETTINGS
try {
siteSettings = await api.getSiteSettings()
} catch (error) {
console.error('Failed to load site settings on maintenance page:', error)
}
const errorMessage =
errorCode === 'empty'
? '请先输入访问口令。'
: errorCode === 'invalid'
? '口令不正确,请重新输入。'
: errorCode === 'unavailable'
? '当前无法校验访问口令,请稍后再试。'
: ''
---
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>{siteSettings.siteName} · 维护模式</title>
</head>
<body class="min-h-screen bg-[var(--bg)] text-[var(--text)]">
<main class="mx-auto flex min-h-screen w-full max-w-6xl items-center px-4 py-10 sm:px-6 lg:px-8">
<section class="terminal-toolbar-shell mx-auto w-full max-w-2xl overflow-hidden rounded-[2rem] p-0">
<div class="border-b border-[var(--border-color)] px-6 py-5 sm:px-8">
<div class="flex items-center gap-3">
<span class="flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--primary)]/10 text-xl font-semibold text-[var(--primary)]">
{siteSettings.siteShortName?.charAt(0) || siteSettings.siteName?.charAt(0) || 'T'}
</span>
<div class="min-w-0">
<p class="terminal-toolbar-label">MAINTENANCE ACCESS</p>
<h1 class="mt-1 text-2xl font-bold text-[var(--title-color)] sm:text-3xl">
{siteSettings.siteName} 正在维护
</h1>
</div>
</div>
</div>
<div class="space-y-6 px-6 py-6 sm:px-8 sm:py-8">
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/80 p-5">
<p class="text-sm leading-7 text-[var(--text-secondary)]">
当前前台内容暂时对外隐藏。你如果拿到了测试口令,可以直接输入进入站点继续浏览;没有口令的话,等我们开放后再访问即可。
</p>
{errorMessage && (
<div class="mt-4 rounded-2xl border border-[var(--danger)]/20 bg-[var(--danger)]/8 px-4 py-3 text-sm text-[var(--danger)]">
{errorMessage}
</div>
)}
</div>
<form method="post" action="/api/maintenance/unlock" class="space-y-4">
<input type="hidden" name="returnTo" value={returnTo} />
<label class="block">
<span class="terminal-form-label">访问口令</span>
<input
type="password"
name="code"
autocomplete="current-password"
placeholder="请输入测试口令"
class="terminal-form-input"
/>
</label>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<button type="submit" class="terminal-action-button terminal-action-button-primary min-w-[10rem]">
进入站点
</button>
<p class="text-sm leading-6 text-[var(--text-tertiary)]">
口令修改后,旧的访问凭证会自动失效。
</p>
</div>
</form>
<div class="rounded-3xl border border-dashed border-[var(--border-color)] bg-[var(--header-bg)]/40 px-5 py-4">
<p class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
Return Target
</p>
<p class="mt-2 font-mono text-sm text-[var(--title-color)]">{returnTo}</p>
</div>
</div>
</section>
</main>
</body>
</html>

View File

@@ -111,9 +111,9 @@ const sharePanelCopy =
'Push the structured rating, status, and canonical review URL into social and AI discovery flows from one compact summary block.',
}
: {
badge: '评测快照',
title: '分享这份评测摘要',
description: '把评分、状态和规范链接一起分发出去,方便用户回访,也方便 AI 在引用时抓到结构化入口。',
badge: '评测详情',
title: '分享这份评测',
description: '把这份评测发出去,方便别人直接看到评分、状态和相关链接。',
};
const jsonLd = review
? [

View File

@@ -253,9 +253,9 @@ const sharePanelCopy = isEnglish
'Use the reviews index as the canonical entry for ratings, statuses, and tagged review snapshots so AI search and readers can drill down from one source.',
}
: {
badge: '评测归档',
title: '分享评测总览页',
description: '把评测归档页当成评分、状态和标签的统一入口分发出去,方便 AI 搜索和读者从一个规范地址继续下钻。',
badge: '评测列表',
title: '分享评测列表',
description: '把评测列表分享出去,方便快速查看评分、状态和分类。',
};
const reviewHighlights = buildDiscoveryHighlights([
t('reviews.subtitle'),

View File

@@ -90,9 +90,9 @@ const sharePanelCopy = isEnglish
tag: 'Tag',
}
: {
badge: '标签聚合',
title: '分享这个标签聚合页',
description: '这个标签页当成专题入口持续扩散,方便读者找关联内容,也方便 AI 检索把引用汇总到同一个规范地址。',
badge: '标签',
title: '分享这个标签页',
description: '分享这个标签页,方便集中查看相关内容。',
posts: '文章数',
tag: '标签',
};

View File

@@ -49,9 +49,9 @@ const sharePanelCopy = isEnglish
site: 'Site',
}
: {
badge: '标签目录',
title: '分享标签总览',
description: '把标签索引页当成全站话题图谱分发出去,方便用户和 AI 检索从统一入口继续找到相关内容簇。',
badge: '标签总览',
title: '分享标签总览',
description: '把标签总览分享出去,方便按关键词继续浏览。',
tags: '标签数',
site: '站点',
};

View File

@@ -83,9 +83,9 @@ const sharePanelCopy = isEnglish
latest: 'Latest',
}
: {
badge: '时间线',
title: '分享站点时间线',
description: '把时间线当成内容演进的规范视图分发出去,方便 AI 搜索和读者理解更新节奏与主题变化。',
badge: '更新时间线',
title: '分享更新时间线',
description: '把时间线页分享出去,方便快速了解内容更新节奏。',
posts: '文章数',
years: '年份数',
latest: '最近年份',

View File

@@ -278,7 +278,7 @@ html.dark {
}
.terminal-chip {
@apply inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-all;
@apply inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] transition-all;
border-color: color-mix(in oklab, var(--primary) 8%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 96%, transparent);
color: var(--text-secondary);
@@ -630,7 +630,7 @@ html.dark {
}
.home-nav-pill {
@apply inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-mono transition-all duration-200;
@apply inline-flex min-w-0 max-w-full items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-mono transition-all duration-200;
border-color: color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
color: var(--text-secondary);
@@ -995,7 +995,7 @@ html.dark {
.ui-filter-pill {
--pill-rgb: var(--primary-rgb);
--pill-fg: var(--text-secondary);
@apply inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] transition-all;
@apply inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-[12px] transition-all;
border-color: color-mix(in oklab, rgb(var(--pill-rgb)) 12%, var(--border-color));
background:
linear-gradient(180deg, color-mix(in oklab, rgb(var(--pill-rgb)) 3%, var(--terminal-bg)), color-mix(in oklab, rgb(var(--pill-rgb)) 1%, var(--header-bg)));

4
frontend/src/types/qrcode.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module 'qrcode' {
const QRCode: any;
export default QRCode;
}