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
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:
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
3
frontend/src/env.d.ts
vendored
3
frontend/src/env.d.ts
vendored
@@ -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>
|
||||
|
||||
@@ -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: 'InitCool,GitHub 用户名 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),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
41
frontend/src/lib/maintenance.ts
Normal file
41
frontend/src/lib/maintenance.ts
Normal 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')
|
||||
)
|
||||
}
|
||||
@@ -84,6 +84,7 @@ export interface SiteSettings {
|
||||
};
|
||||
techStack: string[];
|
||||
musicPlaylist: MusicTrack[];
|
||||
musicEnabled: boolean;
|
||||
ai: {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
55
frontend/src/middleware.ts
Normal file
55
frontend/src/middleware.ts
Normal 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)
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
79
frontend/src/pages/api/maintenance/unlock.ts
Normal file
79
frontend/src/pages/api/maintenance/unlock.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -117,9 +117,9 @@ const sharePanelCopy = isEnglish
|
||||
page: 'Page',
|
||||
}
|
||||
: {
|
||||
badge: '内容归档',
|
||||
title: '分享文章总归档页',
|
||||
description: '把文章归档页当成统一入口分发出去,方便 AI 检索和读者从一个规范地址继续按类型、分类和标签深入浏览。',
|
||||
badge: '文章列表',
|
||||
title: '分享文章列表',
|
||||
description: '把文章列表分享出去,方便继续按分类、标签和类型浏览。',
|
||||
posts: '文章数',
|
||||
categories: '分类数',
|
||||
tags: '标签数',
|
||||
|
||||
@@ -73,9 +73,9 @@ const sharePanelCopy = isEnglish
|
||||
ai: 'AI',
|
||||
}
|
||||
: {
|
||||
badge: 'AI 检索',
|
||||
title: '分享站内 AI 问答页',
|
||||
description: '把这个 AI 问答入口作为基于问题的规范发现页分发出去,方便用户与 AI 都围绕站内稳定来源继续检索。',
|
||||
badge: '问答入口',
|
||||
title: '分享问答页',
|
||||
description: '把这个问答页分享给需要快速检索站内内容的人。',
|
||||
examples: '示例问题',
|
||||
ai: 'AI',
|
||||
};
|
||||
|
||||
@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
|
||||
slug: 'Slug',
|
||||
}
|
||||
: {
|
||||
badge: '分类聚合',
|
||||
title: '分享这个分类聚合页',
|
||||
description: '把这个分类页当成主题入口持续分发,方便用户快速理解,也方便 AI 搜索把同主题信号聚合回这里。',
|
||||
badge: '分类页',
|
||||
title: '分享这个分类页',
|
||||
description: '分享这个分类页,方便集中查看同主题内容。',
|
||||
posts: '文章数',
|
||||
slug: 'Slug',
|
||||
};
|
||||
|
||||
@@ -48,9 +48,9 @@ const sharePanelCopy = isEnglish
|
||||
site: 'Site',
|
||||
}
|
||||
: {
|
||||
badge: '分类目录',
|
||||
title: '分享分类总览页',
|
||||
description: '把分类索引页作为全站主题地图分发出去,方便读者和 AI 搜索从一个规范入口继续下钻到对应专题。',
|
||||
badge: '分类总览',
|
||||
title: '分享分类总览',
|
||||
description: '把分类总览分享出去,方便按主题快速找到内容。',
|
||||
categories: '分类数',
|
||||
site: '站点',
|
||||
};
|
||||
|
||||
@@ -88,9 +88,9 @@ const sharePanelCopy = isEnglish
|
||||
groups: 'Groups',
|
||||
}
|
||||
: {
|
||||
badge: '友链网络',
|
||||
badge: '友情链接',
|
||||
title: '分享友情链接页',
|
||||
description: '把友情链接页当成站点网络地图分发出去,方便 AI 搜索和读者理解这个站点的可信邻居与外部引用关系。',
|
||||
description: '把友情链接页分享出去,方便查看常访问的网站与推荐来源。',
|
||||
links: '友链数',
|
||||
groups: '分组数',
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
99
frontend/src/pages/maintenance.astro
Normal file
99
frontend/src/pages/maintenance.astro
Normal 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>
|
||||
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -90,9 +90,9 @@ const sharePanelCopy = isEnglish
|
||||
tag: 'Tag',
|
||||
}
|
||||
: {
|
||||
badge: '标签聚合',
|
||||
title: '分享这个标签聚合页',
|
||||
description: '把这个标签页当成专题入口持续扩散,方便读者找关联内容,也方便 AI 检索把引用汇总到同一个规范地址。',
|
||||
badge: '标签页',
|
||||
title: '分享这个标签页',
|
||||
description: '分享这个标签页,方便集中查看相关内容。',
|
||||
posts: '文章数',
|
||||
tag: '标签',
|
||||
};
|
||||
|
||||
@@ -49,9 +49,9 @@ const sharePanelCopy = isEnglish
|
||||
site: 'Site',
|
||||
}
|
||||
: {
|
||||
badge: '标签目录',
|
||||
title: '分享标签总览页',
|
||||
description: '把标签索引页当成全站话题图谱分发出去,方便用户和 AI 检索从统一入口继续找到相关内容簇。',
|
||||
badge: '标签总览',
|
||||
title: '分享标签总览',
|
||||
description: '把标签总览分享出去,方便按关键词继续浏览。',
|
||||
tags: '标签数',
|
||||
site: '站点',
|
||||
};
|
||||
|
||||
@@ -83,9 +83,9 @@ const sharePanelCopy = isEnglish
|
||||
latest: 'Latest',
|
||||
}
|
||||
: {
|
||||
badge: '时间线',
|
||||
title: '分享站点时间线',
|
||||
description: '把时间线当成内容演进的规范视图分发出去,方便 AI 搜索和读者理解更新节奏与主题变化。',
|
||||
badge: '更新时间线',
|
||||
title: '分享更新时间线',
|
||||
description: '把时间线页分享出去,方便快速了解内容更新节奏。',
|
||||
posts: '文章数',
|
||||
years: '年份数',
|
||||
latest: '最近年份',
|
||||
|
||||
@@ -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
4
frontend/src/types/qrcode.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module 'qrcode' {
|
||||
const QRCode: any;
|
||||
export default QRCode;
|
||||
}
|
||||
Reference in New Issue
Block a user