Refine frontend navigation, loading UI, and site copy
Some checks failed
docker-images / resolve-build-targets (push) Successful in 6s
ui-regression / playwright-regression (push) Failing after 13m3s
docker-images / build-and-push (admin) (push) Successful in 4s
docker-images / submit-indexnow (push) Has been cancelled
docker-images / build-and-push (frontend) (push) Has been cancelled
docker-images / build-and-push (backend) (push) Has been cancelled

This commit is contained in:
2026-04-03 23:43:30 +08:00
parent 99a57738e0
commit ad44dde886
14 changed files with 2121 additions and 825 deletions

View File

@@ -3,12 +3,12 @@
site_short_name: "Termi" site_short_name: "Termi"
site_url: "https://init.cool" site_url: "https://init.cool"
site_title: "InitCool · 技术笔记与内容档案" site_title: "InitCool · 技术笔记与内容档案"
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。" site_description: "一个认真折腾、偶尔整活的小站。"
hero_title: "欢迎来到 InitCool" hero_title: "欢迎光临,先随便翻翻"
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。" hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
owner_name: "InitCool" owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool" owner_title: "负责把脑洞拧成页面的人"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
owner_avatar_url: "https://github.com/limitcool.png" owner_avatar_url: "https://github.com/limitcool.png"
social_github: "https://github.com/limitcool" social_github: "https://github.com/limitcool"
social_twitter: "" social_twitter: ""

View File

@@ -3,12 +3,12 @@
site_short_name: "Termi" site_short_name: "Termi"
site_url: "https://init.cool" site_url: "https://init.cool"
site_title: "InitCool · 技术笔记与内容档案" site_title: "InitCool · 技术笔记与内容档案"
site_description: "围绕开发实践、产品观察与长期积累整理的中文内容站。" site_description: "一个认真折腾、偶尔整活的小站。"
hero_title: "欢迎来到 InitCool" hero_title: "欢迎光临,先随便翻翻"
hero_subtitle: "记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。" hero_subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。"
owner_name: "InitCool" owner_name: "InitCool"
owner_title: "Rust / Go / Python Developer · Builder @ init.cool" owner_title: "负责把脑洞拧成页面的人"
owner_bio: "InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。" owner_bio: "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。"
owner_avatar_url: "https://github.com/limitcool.png" owner_avatar_url: "https://github.com/limitcool.png"
social_github: "https://github.com/limitcool" social_github: "https://github.com/limitcool"
social_twitter: "" social_twitter: ""

View File

@@ -35,6 +35,14 @@ const navItems = [
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' }, { icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []), ...(aiEnabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
]; ];
const mobileDockItems = [
{ icon: 'fa-house', text: t('common.home'), href: '/' },
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
...(aiEnabled
? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }]
: [{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' }]),
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
];
const localeLinks = SUPPORTED_LOCALES.map((item) => ({ const localeLinks = SUPPORTED_LOCALES.map((item) => ({
locale: item, locale: item,
href: buildLocaleUrl(item), href: buildLocaleUrl(item),
@@ -149,7 +157,7 @@ const currentNavLabel =
{aiEnabled && ( {aiEnabled && (
<a <a
href="/ask" 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)]" class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[color:color-mix(in_oklab,var(--primary)_28%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] shadow-[0_10px_24px_rgba(var(--primary-rgb),0.10)] transition hover:border-[color:color-mix(in_oklab,var(--primary)_40%,var(--border-color))] hover:text-[var(--title-color)]"
> >
<i class="fas fa-robot text-sm"></i> <i class="fas fa-robot text-sm"></i>
<span class="hidden xl:inline">{t('nav.ask')}</span> <span class="hidden xl:inline">{t('nav.ask')}</span>
@@ -357,6 +365,40 @@ const currentNavLabel =
</div> </div>
</header> </header>
<div class="fixed inset-x-0 bottom-0 z-40 px-3 pb-[calc(0.8rem+env(safe-area-inset-bottom))] lg:hidden">
<div class="mx-auto max-w-md">
<div class="grid grid-cols-5 gap-1 rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_96%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent))] p-1.5 shadow-[0_18px_36px_rgba(15,23,42,0.22)] backdrop-blur-xl">
{mobileDockItems.map((item) => {
const isActive = currentPath === item.href || (item.href !== '/' && currentPath.startsWith(item.href));
return (
<a
href={item.href}
class:list={[
'flex min-w-0 flex-col items-center gap-1 rounded-[18px] px-2 py-2 text-[11px] font-medium transition',
isActive
? 'border border-[color:color-mix(in_oklab,var(--primary)_30%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_14%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] text-[var(--primary)]'
: 'border border-transparent text-[var(--text-secondary)] hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]',
]}
aria-current={isActive ? 'page' : undefined}
>
<i class={`fas ${item.icon} text-[13px]`}></i>
<span class="truncate">{item.text}</span>
</a>
);
})}
<button
type="button"
class="flex min-w-0 flex-col items-center gap-1 rounded-[18px] border border-transparent px-2 py-2 text-[11px] font-medium text-[var(--text-secondary)] transition hover:bg-[color-mix(in_oklab,var(--primary)_6%,var(--terminal-bg))] hover:text-[var(--title-color)]"
data-mobile-dock-menu
aria-label={t('header.toggleMenu')}
>
<i class="fas fa-bars text-[13px]"></i>
<span class="truncate">{t('header.navigation')}</span>
</button>
</div>
</div>
</div>
<script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}> <script is:inline define:vars={{ apiBase: publicApiBaseUrl, musicPlaylistPayload }}>
const t = window.__termiTranslate; const t = window.__termiTranslate;
@@ -365,12 +407,16 @@ const currentNavLabel =
const mobileMenu = document.getElementById('mobile-menu'); const mobileMenu = document.getElementById('mobile-menu');
const mobileSearchInput = document.getElementById('mobile-search-input'); const mobileSearchInput = document.getElementById('mobile-search-input');
const mobileSearchBtn = document.getElementById('mobile-search-btn'); const mobileSearchBtn = document.getElementById('mobile-search-btn');
const mobileDockMenuBtn = document.querySelector('[data-mobile-dock-menu]');
mobileMenuBtn?.addEventListener('click', () => { function toggleMobileMenu() {
const nextExpanded = mobileMenu?.classList.contains('hidden'); const nextExpanded = mobileMenu?.classList.contains('hidden');
mobileMenu?.classList.toggle('hidden'); mobileMenu?.classList.toggle('hidden');
mobileMenuBtn.setAttribute('aria-expanded', String(nextExpanded)); mobileMenuBtn?.setAttribute('aria-expanded', String(nextExpanded));
}); }
mobileMenuBtn?.addEventListener('click', toggleMobileMenu);
mobileDockMenuBtn?.addEventListener('click', toggleMobileMenu);
document.querySelectorAll('#mobile-menu a[href]').forEach((link) => { document.querySelectorAll('#mobile-menu a[href]').forEach((link) => {
link.addEventListener('click', () => { link.addEventListener('click', () => {

View File

@@ -1021,7 +1021,8 @@ const webPushAvailable = Boolean(webPushPublicKey);
gap: 1rem; gap: 1rem;
margin-top: var(--subscription-popup-offset, calc(env(safe-area-inset-top, 0px) + 5.25rem)); margin-top: var(--subscription-popup-offset, calc(env(safe-area-inset-top, 0px) + 5.25rem));
padding: 1.1rem; padding: 1.1rem;
border-radius: 1.7rem; padding-top: 3.5rem;
border-radius: 1.55rem;
opacity: 0; opacity: 0;
transform: translateY(-1rem) scale(0.985); transform: translateY(-1rem) scale(0.985);
transition: transition:
@@ -1031,16 +1032,20 @@ const webPushAvailable = Boolean(webPushPublicKey);
overflow: hidden; overflow: hidden;
backdrop-filter: blur(16px) saturate(135%); backdrop-filter: blur(16px) saturate(135%);
background: background:
radial-gradient(circle at top left, rgba(var(--primary-rgb), 0.15), transparent 26%), linear-gradient(
radial-gradient(circle at bottom right, rgba(var(--secondary-rgb, var(--primary-rgb)), 0.1), transparent 28%), 135deg,
rgba(var(--primary-rgb), 0.09),
rgba(var(--secondary-rgb, var(--primary-rgb)), 0.04) 42%,
transparent 72%
),
linear-gradient( linear-gradient(
180deg, 180deg,
color-mix(in oklab, var(--terminal-bg) 99%, white), color-mix(in oklab, var(--terminal-bg) 97%, transparent),
color-mix(in oklab, var(--header-bg) 93%, white) color-mix(in oklab, var(--header-bg) 92%, transparent)
); );
box-shadow: box-shadow:
0 28px 70px rgba(var(--text-rgb), 0.14), 0 28px 70px rgba(var(--text-rgb), 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.34); inset 0 1px 0 rgba(255, 255, 255, 0.22);
} }
.subscription-popup-panel::before { .subscription-popup-panel::before {
@@ -1066,20 +1071,25 @@ const webPushAvailable = Boolean(webPushPublicKey);
position: absolute; position: absolute;
top: 0.8rem; top: 0.8rem;
right: 0.8rem; right: 0.8rem;
z-index: 3;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 2.15rem; width: 2.15rem;
height: 2.15rem; height: 2.15rem;
border-radius: 999px; border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color)); border: 1px solid color-mix(in oklab, var(--primary) 16%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 94%, transparent); background: color-mix(in oklab, var(--header-bg) 90%, var(--terminal-bg));
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: pointer; cursor: pointer;
transition: transition:
border-color 0.2s ease, border-color 0.2s ease,
color 0.2s ease, color 0.2s ease,
transform 0.2s ease; transform 0.2s ease,
box-shadow 0.2s ease;
box-shadow:
0 10px 22px rgba(var(--text-rgb), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
} }
.subscription-popup-close:hover { .subscription-popup-close:hover {
@@ -1097,23 +1107,25 @@ const webPushAvailable = Boolean(webPushPublicKey);
.subscription-popup-main { .subscription-popup-main {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
position: relative;
z-index: 1;
} }
.subscription-popup-copy-surface, .subscription-popup-copy-surface,
.subscription-popup-channel-card { .subscription-popup-channel-card {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 1.35rem; border-radius: 1.2rem;
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color)); border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: background:
linear-gradient( linear-gradient(
180deg, 180deg,
color-mix(in oklab, var(--terminal-bg) 99%, white), color-mix(in oklab, var(--terminal-bg) 97%, transparent),
color-mix(in oklab, var(--header-bg) 94%, white) color-mix(in oklab, var(--header-bg) 91%, transparent)
); );
box-shadow: box-shadow:
0 12px 30px rgba(var(--text-rgb), 0.05), 0 12px 30px rgba(var(--text-rgb), 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.42); inset 0 1px 0 rgba(255, 255, 255, 0.18);
} }
.subscription-popup-copy-surface { .subscription-popup-copy-surface {
@@ -1167,7 +1179,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
min-height: 3.35rem; min-height: 3.35rem;
border-radius: 1rem; border-radius: 1rem;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color)); border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 98%, white); background: color-mix(in oklab, var(--terminal-bg) 97%, transparent);
color: var(--text-secondary); color: var(--text-secondary);
padding: 0.85rem 1rem; padding: 0.85rem 1rem;
text-align: left; text-align: left;
@@ -1191,7 +1203,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color)); border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg)); background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
color: var(--primary); color: var(--primary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
} }
.subscription-popup-channel-toggle-copy { .subscription-popup-channel-toggle-copy {
@@ -1241,11 +1253,11 @@ const webPushAvailable = Boolean(webPushPublicKey);
} }
.subscription-popup-channel-toggle--primary { .subscription-popup-channel-toggle--primary {
border-color: color-mix(in oklab, var(--primary) 52%, white); border-color: color-mix(in oklab, var(--primary) 48%, var(--border-color));
background: background:
linear-gradient( linear-gradient(
180deg, 180deg,
color-mix(in oklab, var(--primary) 88%, white), color-mix(in oklab, var(--primary) 82%, white),
color-mix(in oklab, var(--primary) 72%, var(--header-bg)) color-mix(in oklab, var(--primary) 72%, var(--header-bg))
); );
color: white; color: white;
@@ -1285,7 +1297,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
background: background:
linear-gradient( linear-gradient(
180deg, 180deg,
color-mix(in oklab, var(--secondary, var(--primary)) 26%, white), color-mix(in oklab, var(--secondary, var(--primary)) 22%, var(--terminal-bg)),
color-mix(in oklab, var(--secondary, var(--primary)) 14%, var(--header-bg)) color-mix(in oklab, var(--secondary, var(--primary)) 14%, var(--header-bg))
); );
box-shadow: box-shadow:
@@ -1490,11 +1502,11 @@ const webPushAvailable = Boolean(webPushPublicKey);
background: background:
linear-gradient( linear-gradient(
180deg, 180deg,
color-mix(in oklab, var(--header-bg) 96%, white), color-mix(in oklab, var(--header-bg) 94%, transparent),
color-mix(in oklab, var(--terminal-bg) 98%, white) color-mix(in oklab, var(--terminal-bg) 97%, transparent)
); );
box-shadow: box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 14px 32px rgba(var(--text-rgb), 0.05); 0 14px 32px rgba(var(--text-rgb), 0.05);
} }
@@ -1589,7 +1601,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color)); border: 1px solid color-mix(in oklab, var(--primary) 14%, var(--border-color));
background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg)); background: color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
color: var(--primary); color: var(--primary);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.34); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
} }
.subscription-popup-channel-icon--mail { .subscription-popup-channel-icon--mail {
@@ -1615,7 +1627,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
margin-top: 0.15rem; margin-top: 0.15rem;
border-radius: 1.05rem; border-radius: 1.05rem;
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color)); border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--header-bg) 65%, white); background: color-mix(in oklab, var(--header-bg) 88%, var(--terminal-bg));
padding: 0.9rem 0.95rem; padding: 0.9rem 0.95rem;
} }
@@ -1736,7 +1748,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
.subscription-popup-panel { .subscription-popup-panel {
gap: 0.9rem; gap: 0.9rem;
padding: 0.95rem 0.9rem 0.95rem; padding: 3.1rem 0.9rem 0.95rem;
} }
.subscription-popup-copy-mark { .subscription-popup-copy-mark {
@@ -1756,7 +1768,7 @@ const webPushAvailable = Boolean(webPushPublicKey);
.subscription-popup-copy-surface, .subscription-popup-copy-surface,
.subscription-popup-channel-card { .subscription-popup-channel-card {
border-radius: 1.2rem; border-radius: 1.05rem;
} }
.subscription-popup-copy-surface { .subscription-popup-copy-surface {

View File

@@ -16,7 +16,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
<div <div
id="toc-panel" id="toc-panel"
class="rounded-[24px] border border-[var(--border-color)]/72 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(250,252,255,0.92))] p-4 shadow-[0_14px_34px_rgba(15,23,42,0.055)] backdrop-blur" class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.045),transparent_56%)] p-4 shadow-[0_14px_34px_rgba(15,23,42,0.08)] backdrop-blur"
> >
<div class="space-y-4"> <div class="space-y-4">
<span class="terminal-kicker w-fit"> <span class="terminal-kicker w-fit">
@@ -130,8 +130,8 @@ const hasBeforeNav = Astro.slots.has('before-nav');
height: 0.52rem; height: 0.52rem;
margin-top: 0.42rem; margin-top: 0.42rem;
border-radius: 999px; border-radius: 999px;
background: color-mix(in oklab, var(--border-color) 82%, white 18%); background: color-mix(in oklab, var(--border-color) 82%, var(--terminal-bg));
box-shadow: 0 0 0 6px color-mix(in oklab, var(--card-bg, white) 92%, transparent); box-shadow: 0 0 0 6px color-mix(in oklab, var(--terminal-bg) 92%, transparent);
transition: transition:
background-color 160ms ease, background-color 160ms ease,
transform 160ms ease, transform 160ms ease,
@@ -145,7 +145,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
#toc-nav .toc-link.is-active .toc-link-dot { #toc-nav .toc-link.is-active .toc-link-dot {
background: var(--primary); background: var(--primary);
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 0 0 6px color-mix(in oklab, var(--primary) 10%, white 90%); box-shadow: 0 0 0 6px color-mix(in oklab, var(--primary) 10%, var(--terminal-bg));
} }
#toc-nav .toc-link-sub .toc-link-dot { #toc-nav .toc-link-sub .toc-link-dot {
@@ -189,6 +189,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
const tocPanel = document.getElementById('toc-panel'); const tocPanel = document.getElementById('toc-panel');
const container = document.getElementById('toc-container'); const container = document.getElementById('toc-container');
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true'; const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
const header = document.querySelector('header');
if (!tocNav || headings.length === 0) { if (!tocNav || headings.length === 0) {
if (tocPanel) tocPanel.style.display = 'none'; if (tocPanel) tocPanel.style.display = 'none';
@@ -196,6 +197,47 @@ const hasBeforeNav = Astro.slots.has('before-nav');
return; return;
} }
const getScrollOffset = () => {
const headerRect = header instanceof HTMLElement ? header.getBoundingClientRect() : null;
const headerHeight = headerRect ? Math.max(headerRect.height, headerRect.bottom) : 0;
return Math.round(headerHeight + 20);
};
const updateHeadingOffset = () => {
const offset = `${getScrollOffset()}px`;
headings.forEach((heading) => {
if (heading instanceof HTMLElement) {
heading.style.scrollMarginTop = offset;
}
});
};
const scrollToHeading = (heading) => {
const offset = getScrollOffset();
const targetTop = window.scrollY + heading.getBoundingClientRect().top - offset;
window.scrollTo({
top: Math.max(targetTop, 0),
behavior: 'smooth',
});
};
const setActiveLink = (headingId) => {
const links = tocNav.querySelectorAll('a');
links.forEach((link) => {
const isActive = link.getAttribute('href') === `#${headingId}`;
link.classList.toggle('is-active', isActive);
if (isActive) {
link.setAttribute('aria-current', 'true');
link.scrollIntoView({ block: 'nearest', inline: 'nearest' });
} else {
link.removeAttribute('aria-current');
}
});
};
tocNav.innerHTML = ''; tocNav.innerHTML = '';
headings.forEach((heading, index) => { headings.forEach((heading, index) => {
if (!heading.id) { if (!heading.id) {
@@ -212,32 +254,103 @@ const hasBeforeNav = Astro.slots.has('before-nav');
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth', block: 'start' }); setActiveLink(heading.id);
scrollToHeading(heading);
window.history.replaceState(null, '', `#${heading.id}`);
}); });
tocNav.appendChild(link); tocNav.appendChild(link);
}); });
const observer = new IntersectionObserver( updateHeadingOffset();
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const links = tocNav.querySelectorAll('a');
links.forEach(link => {
link.classList.remove('is-active');
link.removeAttribute('aria-current');
if (link.getAttribute('href') === `#${entry.target.id}`) {
link.classList.add('is-active');
link.setAttribute('aria-current', 'true');
}
});
}
});
},
{ rootMargin: '-20% 0px -75% 0px' }
);
headings.forEach(heading => observer.observe(heading)); let ticking = false;
let scrollEndTimer = 0;
let suppressScrollTracking = false;
const syncActiveHeading = () => {
if (suppressScrollTracking) {
return;
}
const offset = getScrollOffset();
const threshold = offset + 24;
const headingList = Array.from(headings).filter((heading) => heading instanceof HTMLElement);
let activeHeading = headingList[0] || null;
headingList.forEach((heading) => {
if (heading.getBoundingClientRect().top <= threshold) {
activeHeading = heading;
}
});
if (!activeHeading) {
return;
}
const nearBottom =
window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 8;
if (nearBottom) {
activeHeading = headingList[headingList.length - 1] || activeHeading;
}
setActiveLink(activeHeading.id);
};
const requestActiveHeadingSync = () => {
if (ticking) {
return;
}
ticking = true;
requestAnimationFrame(() => {
ticking = false;
syncActiveHeading();
});
};
const releaseScrollTrackingSoon = () => {
window.clearTimeout(scrollEndTimer);
scrollEndTimer = window.setTimeout(() => {
suppressScrollTracking = false;
syncActiveHeading();
}, 220);
};
window.addEventListener('scroll', () => {
releaseScrollTrackingSoon();
requestActiveHeadingSync();
}, { passive: true });
window.addEventListener('resize', () => {
updateHeadingOffset();
requestActiveHeadingSync();
}, { passive: true });
if (window.location.hash) {
const target = document.getElementById(window.location.hash.slice(1));
if (target instanceof HTMLElement) {
requestAnimationFrame(() => {
suppressScrollTracking = true;
setActiveLink(target.id);
scrollToHeading(target);
releaseScrollTrackingSoon();
});
}
} else if (headings[0] instanceof HTMLElement) {
setActiveLink(headings[0].id);
}
tocNav.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => {
suppressScrollTracking = true;
releaseScrollTrackingSoon();
});
});
requestAnimationFrame(syncActiveHeading);
} }
if (document.readyState === 'loading') { if (document.readyState === 'loading') {

View File

@@ -31,7 +31,7 @@ const {
title = isEnglish ? 'Quick share' : '一键分享', title = isEnglish ? 'Quick share' : '一键分享',
description = isEnglish description = isEnglish
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.' ? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。', : '复制链接、带走二维码,轻轻一发,不剧透太多。',
stats = [], stats = [],
wechatShareQrEnabled = false, wechatShareQrEnabled = false,
variant = 'default', variant = 'default',
@@ -70,30 +70,30 @@ const copy = isEnglish
toastInfoTitle: 'Share ready', toastInfoTitle: 'Share ready',
} }
: { : {
summaryTitle: '页面简介', summaryTitle: '小纸条',
canonical: '固定链接', canonical: '传送门',
copySummary: '复制简介', copySummary: '复制小纸条',
copySummarySuccess: '页面简介已复制', copySummarySuccess: '小纸条已复制',
copySummaryFailed: '复制失败', copySummaryFailed: '复制失败',
copyLink: '复制固定链接', copyLink: '复制传送门',
copyLinkSuccess: '固定链接已复制', copyLinkSuccess: '传送门已复制',
copyLinkFailed: '固定链接复制失败', copyLinkFailed: '传送门复制失败',
shareSummary: '直接分享', shareSummary: '一键甩出',
shareSuccess: '已打开系统分享', shareSuccess: '系统分享已就位',
shareFallback: '分享内容已复制', shareFallback: '分享话术已复制',
shareFailed: '分享失败', shareFailed: '分享失败',
shareToX: '分享到 X', shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram', shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫一扫', shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫一扫', qrModalTitle: '微信扫一扫',
qrModalDescription: '用微信扫一扫,就能在手机上继续浏览当前页面。', qrModalDescription: '扫一下,手机上继续逛。',
qrModalHint: '如果要发给别人,直接复制下方链接会更方便。', qrModalHint: '真要转发,丢链接通常更省事。',
downloadQr: '下载二维码', downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载', downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开', qrOpened: '微信二维码已打开',
toastSuccessTitle: '操作完成', toastSuccessTitle: '搞定',
toastErrorTitle: '操作失败', toastErrorTitle: '这次没接住',
toastInfoTitle: '已准备好', toastInfoTitle: '可以发了',
}; };
const safeSummary = summary.trim() || shareTitle; const safeSummary = summary.trim() || shareTitle;
@@ -140,7 +140,9 @@ if (wechatShareQrEnabled) {
<section <section
class:list={[ class:list={[
'border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.1),rgba(var(--secondary-rgb),0.04)_46%,rgba(var(--bg-rgb),0.92))]', 'border shadow-[0_16px_40px_rgba(15,23,42,0.08)]',
'border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))]',
'bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.08),rgba(var(--secondary-rgb),0.04)_46%,transparent)]',
isCompact ? 'rounded-[24px] p-4' : 'rounded-[28px] p-5 sm:p-6', isCompact ? 'rounded-[24px] p-4' : 'rounded-[28px] p-5 sm:p-6',
]} ]}
data-share-panel-id={panelId} data-share-panel-id={panelId}
@@ -148,7 +150,7 @@ if (wechatShareQrEnabled) {
<div class:list={['flex flex-col gap-4', !isCompact && 'lg:flex-row lg:items-start lg:justify-between']}> <div class:list={['flex flex-col gap-4', !isCompact && 'lg:flex-row lg:items-start lg:justify-between']}>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex flex-wrap items-center gap-2"> <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)]"> <span class="inline-flex items-center gap-2 rounded-full border border-[color:color-mix(in_oklab,var(--primary)_30%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--primary)_15%,var(--terminal-bg)),color-mix(in_oklab,var(--primary)_8%,var(--terminal-bg)))] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)] shadow-[inset_0_1px_0_rgba(255,255,255,0.24)]">
<i class="fas fa-satellite-dish text-[10px]"></i> <i class="fas fa-satellite-dish text-[10px]"></i>
{visibleBadge} {visibleBadge}
</span> </span>
@@ -167,7 +169,7 @@ if (wechatShareQrEnabled) {
{stats.length > 0 ? ( {stats.length > 0 ? (
<div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && 'lg:min-w-[16rem]']}> <div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && 'lg:min-w-[16rem]']}>
{stats.slice(0, 4).map((item) => ( {stats.slice(0, 4).map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/76 px-4 py-3"> <div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_96%,transparent)] px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div> <div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
<div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div> <div class="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
</div> </div>
@@ -177,7 +179,7 @@ if (wechatShareQrEnabled) {
</div> </div>
<div class:list={[ <div class:list={[
'rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/86 shadow-[0_16px_40px_rgba(15,23,42,0.06)]', 'rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] shadow-[0_16px_40px_rgba(15,23,42,0.06)]',
isCompact ? 'mt-4 p-4' : 'mt-5 p-5', isCompact ? 'mt-4 p-4' : 'mt-5 p-5',
]}> ]}>
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div> <div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
@@ -186,6 +188,91 @@ if (wechatShareQrEnabled) {
</p> </p>
</div> </div>
{isCompact ? (
<div class="share-panel-compact-actions mt-4">
<div class="share-panel-compact-actions__head">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share actions' : '分享操作'}
</p>
</div>
<div class="share-panel-compact-actions__grid share-panel-compact-actions__grid--utility">
<button
type="button"
class="terminal-action-button"
data-share-copy-summary
data-default-label={copy.copySummary}
data-success-label={copy.copySummarySuccess}
data-failed-label={copy.copySummaryFailed}
>
<i class="fas fa-copy"></i>
<span>{copy.copySummary}</span>
</button>
<button
type="button"
class="terminal-action-button"
data-share-copy-link
data-default-label={copy.copyLink}
data-success-label={copy.copyLinkSuccess}
data-failed-label={copy.copyLinkFailed}
>
<i class="fas fa-link"></i>
<span>{copy.copyLink}</span>
</button>
</div>
<div class="share-panel-compact-actions__primary">
<button
type="button"
class="terminal-action-button terminal-action-button-primary w-full"
data-share-summary
data-default-label={copy.shareSummary}
data-success-label={copy.shareSuccess}
data-fallback-label={copy.shareFallback}
data-failed-label={copy.shareFailed}
>
<i class="fas fa-share-nodes"></i>
<span>{copy.shareSummary}</span>
</button>
</div>
<div class="share-panel-compact-actions__grid share-panel-compact-actions__grid--channels">
<a
href={xShareUrl}
target="_blank"
rel="noopener noreferrer nofollow"
class="terminal-action-button"
data-share-link
>
<i class="fab fa-twitter"></i>
<span>{copy.shareToX}</span>
</a>
<a
href={telegramShareUrl}
target="_blank"
rel="noopener noreferrer nofollow"
class="terminal-action-button"
data-share-link
>
<i class="fab fa-telegram-plane"></i>
<span>{copy.shareToTelegram}</span>
</a>
{wechatShareQrEnabled && wechatShareQrSvg ? (
<button
type="button"
class="terminal-action-button share-panel-compact-actions__wechat"
data-share-wechat-open
>
<i class="fab fa-weixin"></i>
<span>{copy.shareToWeChat}</span>
</button>
) : null}
</div>
<p class="share-panel-compact-actions__status text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
</div>
) : (
<>
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center"> <div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
@@ -226,13 +313,13 @@ if (wechatShareQrEnabled) {
<p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p> <p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
</div> </div>
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3"> <div class="mt-4 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_90%,transparent))] px-4 py-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]"> <p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share channels' : '分享渠道'} {isEnglish ? 'Share channels' : '分享渠道'}
</p> </p>
{!isCompact && <p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>} <p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<a <a
@@ -268,20 +355,24 @@ if (wechatShareQrEnabled) {
</div> </div>
</div> </div>
</div> </div>
</>
)}
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4"> <div class="mt-4 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_97%,transparent)] p-4">
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.canonical}</div> <div class="text-[11px] uppercase tracking-[0.18em] 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-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
</div> </div>
{wechatShareQrEnabled && wechatShareQrSvg ? ( {wechatShareQrEnabled && wechatShareQrSvg ? (
<div <div
class="fixed inset-0 z-[160] hidden bg-black/70 backdrop-blur-sm" class="share-wechat-modal-overlay fixed inset-0 z-[160] hidden"
data-share-wechat-modal data-share-wechat-modal
aria-hidden="true" aria-hidden="true"
> >
<div class="flex min-h-screen items-center justify-center p-4"> <div class="flex min-h-screen items-center justify-center p-4">
<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="share-wechat-modal-card relative isolate w-full max-w-4xl overflow-hidden">
<div class="share-wechat-modal-card__line absolute inset-x-0 top-0 h-px"></div>
<div class="relative z-[1] p-5 sm:p-7">
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="space-y-2"> <div class="space-y-2">
<span class="terminal-kicker"> <span class="terminal-kicker">
@@ -298,7 +389,7 @@ if (wechatShareQrEnabled) {
<button <button
type="button" type="button"
class="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]" class="share-wechat-modal-close flex h-11 w-11 items-center justify-center rounded-2xl text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-share-wechat-close data-share-wechat-close
aria-label={t('common.close')} aria-label={t('common.close')}
> >
@@ -307,19 +398,19 @@ if (wechatShareQrEnabled) {
</div> </div>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]"> <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="share-wechat-modal-surface mx-auto w-full max-w-[260px] rounded-[28px] p-5">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div> <div class="overflow-hidden rounded-2xl bg-white" set:html={wechatShareQrSvg}></div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5"> <div class="share-wechat-modal-surface rounded-2xl p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical} {copy.canonical}
</div> </div>
<p class="mt-3 break-all font-mono text-sm leading-7 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>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5"> <div class="share-wechat-modal-surface rounded-2xl p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle} {copy.summaryTitle}
</div> </div>
@@ -363,6 +454,7 @@ if (wechatShareQrEnabled) {
</div> </div>
</div> </div>
</div> </div>
</div>
) : null} ) : null}
<div <div
@@ -393,6 +485,118 @@ if (wechatShareQrEnabled) {
</div> </div>
</section> </section>
<style is:inline>
.share-panel-compact-actions {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
padding: 0.95rem;
border-radius: 1.25rem;
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 97%, transparent),
color-mix(in oklab, var(--header-bg) 90%, transparent)
);
box-shadow:
0 14px 34px rgba(var(--text-rgb), 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.share-panel-compact-actions__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.share-panel-compact-actions__grid {
display: grid;
gap: 0.5rem;
}
.share-panel-compact-actions__grid--utility {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.share-panel-compact-actions__grid--channels {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.share-panel-compact-actions__grid--channels > :last-child:nth-child(odd) {
grid-column: 1 / -1;
}
.share-panel-compact-actions__primary .terminal-action-button,
.share-panel-compact-actions__grid .terminal-action-button {
min-width: 0;
width: 100%;
}
.share-panel-compact-actions__grid .terminal-action-button span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.share-panel-compact-actions__status {
min-height: 1rem;
margin: -0.1rem 0 0;
}
.share-wechat-modal-card {
border-radius: 32px;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background-color: var(--terminal-bg);
box-shadow: 0 30px 90px rgba(15, 23, 42, 0.36);
}
.share-wechat-modal-card::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 92%, var(--bg)),
color-mix(in oklab, var(--terminal-bg) 84%, var(--bg))
),
linear-gradient(
135deg,
rgba(var(--primary-rgb), 0.055),
rgba(var(--secondary-rgb, var(--primary-rgb)), 0.03) 46%,
transparent 82%
);
opacity: 1;
}
.share-wechat-modal-overlay {
background: rgba(15, 23, 42, 0.72);
backdrop-filter: blur(10px);
}
.share-wechat-modal-card__line {
background: linear-gradient(90deg, transparent, rgba(var(--primary-rgb), 0.4), transparent);
}
.share-wechat-modal-close {
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background: color-mix(in oklab, var(--terminal-bg) 97%, white 3%);
box-shadow:
0 10px 24px rgba(var(--text-rgb), 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
}
.share-wechat-modal-surface {
border: 1px solid color-mix(in oklab, var(--primary) 10%, var(--border-color));
background-color: color-mix(in oklab, var(--terminal-bg) 90%, var(--bg));
box-shadow:
0 16px 36px rgba(var(--text-rgb), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
}
</style>
<script <script
is:inline is:inline
define:vars={{ define:vars={{
@@ -424,6 +628,10 @@ if (wechatShareQrEnabled) {
const toastMessage = root.querySelector('[data-share-toast-message]'); const toastMessage = root.querySelector('[data-share-toast-message]');
let toastTimer = 0; let toastTimer = 0;
if (wechatModal instanceof HTMLElement && wechatModal.parentElement !== document.body) {
document.body.appendChild(wechatModal);
}
function setStatus(message) { function setStatus(message) {
if (!status) return; if (!status) return;
status.textContent = message || ''; status.textContent = message || '';

View File

@@ -501,7 +501,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
<Header siteSettings={siteSettings} /> <Header siteSettings={siteSettings} />
<main class="flex-1 w-full"> <main class="flex-1 w-full pb-[calc(5.5rem+env(safe-area-inset-bottom))] lg:pb-0">
<slot /> <slot />
</main> </main>

View File

@@ -8,27 +8,29 @@ import type {
PopularPostHighlight, PopularPostHighlight,
SiteSettings, SiteSettings,
Tag as UiTag, Tag as UiTag,
} from '../types'; } from "../types";
const DEV_API_BASE_URL = 'http://127.0.0.1:5150/api'; const DEV_API_BASE_URL = "http://127.0.0.1:5150/api";
const PROD_DEFAULT_API_PORT = '5150'; const PROD_DEFAULT_API_PORT = "5150";
function normalizeApiBaseUrl(value?: string | null) { function normalizeApiBaseUrl(value?: string | null) {
return value?.trim().replace(/\/$/, '') ?? ''; return value?.trim().replace(/\/$/, "") ?? "";
} }
function getRuntimeEnv( function getRuntimeEnv(
name: name:
| 'PUBLIC_API_BASE_URL' | "PUBLIC_API_BASE_URL"
| 'INTERNAL_API_BASE_URL' | "INTERNAL_API_BASE_URL"
| 'PUBLIC_COMMENT_TURNSTILE_SITE_KEY' | "PUBLIC_COMMENT_TURNSTILE_SITE_KEY"
| 'PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY', | "PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY",
) { ) {
const runtimeProcess = (globalThis as typeof globalThis & { const runtimeProcess = (
globalThis as typeof globalThis & {
process?: { process?: {
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
}; };
}).process; }
).process;
return normalizeApiBaseUrl(runtimeProcess?.env?.[name]); return normalizeApiBaseUrl(runtimeProcess?.env?.[name]);
} }
@@ -41,28 +43,30 @@ function normalizeVerificationMode(
value: string | null | undefined, value: string | null | undefined,
fallback: HumanVerificationMode, fallback: HumanVerificationMode,
): HumanVerificationMode { ): HumanVerificationMode {
switch ((value ?? '').trim().toLowerCase()) { switch ((value ?? "").trim().toLowerCase()) {
case 'off': case "off":
return 'off'; return "off";
case 'captcha': case "captcha":
case 'normal': case "normal":
case 'simple': case "simple":
return 'captcha'; return "captcha";
case 'turnstile': case "turnstile":
return 'turnstile'; return "turnstile";
default: default:
return fallback; return fallback;
} }
} }
const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(import.meta.env.PUBLIC_API_BASE_URL); const buildTimePublicApiBaseUrl = normalizeApiBaseUrl(
import.meta.env.PUBLIC_API_BASE_URL,
);
const buildTimeCommentTurnstileSiteKey = const buildTimeCommentTurnstileSiteKey =
import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? ''; import.meta.env.PUBLIC_COMMENT_TURNSTILE_SITE_KEY?.trim() ?? "";
const buildTimeWebPushVapidPublicKey = const buildTimeWebPushVapidPublicKey =
import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? ''; import.meta.env.PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY?.trim() ?? "";
export function resolvePublicApiBaseUrl(requestUrl?: string | URL) { export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
const runtimePublicApiBaseUrl = getRuntimeEnv('PUBLIC_API_BASE_URL'); const runtimePublicApiBaseUrl = getRuntimeEnv("PUBLIC_API_BASE_URL");
if (runtimePublicApiBaseUrl) { if (runtimePublicApiBaseUrl) {
return runtimePublicApiBaseUrl; return runtimePublicApiBaseUrl;
} }
@@ -84,7 +88,7 @@ export function resolvePublicApiBaseUrl(requestUrl?: string | URL) {
} }
export function resolveInternalApiBaseUrl(requestUrl?: string | URL) { export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
const runtimeInternalApiBaseUrl = getRuntimeEnv('INTERNAL_API_BASE_URL'); const runtimeInternalApiBaseUrl = getRuntimeEnv("INTERNAL_API_BASE_URL");
if (runtimeInternalApiBaseUrl) { if (runtimeInternalApiBaseUrl) {
return runtimeInternalApiBaseUrl; return runtimeInternalApiBaseUrl;
} }
@@ -94,13 +98,15 @@ export function resolveInternalApiBaseUrl(requestUrl?: string | URL) {
export function resolvePublicCommentTurnstileSiteKey() { export function resolvePublicCommentTurnstileSiteKey() {
return ( return (
getRuntimeEnv('PUBLIC_COMMENT_TURNSTILE_SITE_KEY') || buildTimeCommentTurnstileSiteKey getRuntimeEnv("PUBLIC_COMMENT_TURNSTILE_SITE_KEY") ||
buildTimeCommentTurnstileSiteKey
); );
} }
export function resolvePublicWebPushVapidPublicKey() { export function resolvePublicWebPushVapidPublicKey() {
return ( return (
getRuntimeEnv('PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY') || buildTimeWebPushVapidPublicKey getRuntimeEnv("PUBLIC_WEB_PUSH_VAPID_PUBLIC_KEY") ||
buildTimeWebPushVapidPublicKey
); );
} }
@@ -114,12 +120,12 @@ export interface ApiPost {
content: string; content: string;
category: string; category: string;
tags: string[]; tags: string[];
post_type: 'article' | 'tweet'; post_type: "article" | "tweet";
image: string | null; image: string | null;
images: string[] | null; images: string[] | null;
pinned: boolean; pinned: boolean;
status: string | null; status: string | null;
visibility: 'public' | 'unlisted' | 'private' | null; visibility: "public" | "unlisted" | "private" | null;
publish_at: string | null; publish_at: string | null;
unpublish_at: string | null; unpublish_at: string | null;
canonical_url: string | null; canonical_url: string | null;
@@ -144,7 +150,7 @@ export interface Comment {
content: string | null; content: string | null;
reply_to: string | null; reply_to: string | null;
reply_to_comment_id: number | null; reply_to_comment_id: number | null;
scope: 'article' | 'paragraph'; scope: "article" | "paragraph";
paragraph_key: string | null; paragraph_key: string | null;
paragraph_excerpt: string | null; paragraph_excerpt: string | null;
approved: boolean | null; approved: boolean | null;
@@ -157,7 +163,7 @@ export interface CreateCommentInput {
nickname: string; nickname: string;
email?: string; email?: string;
content: string; content: string;
scope?: 'article' | 'paragraph'; scope?: "article" | "paragraph";
paragraphKey?: string; paragraphKey?: string;
paragraphExcerpt?: string; paragraphExcerpt?: string;
replyTo?: string | null; replyTo?: string | null;
@@ -210,7 +216,7 @@ export interface ApiFriendLink {
avatar_url: string | null; avatar_url: string | null;
description: string | null; description: string | null;
category: string | null; category: string | null;
status: 'pending' | 'approved' | 'rejected'; status: "pending" | "approved" | "rejected";
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -300,7 +306,7 @@ export interface ApiSiteSettings {
} }
export interface ContentAnalyticsInput { export interface ContentAnalyticsInput {
eventType: 'page_view' | 'read_progress' | 'read_complete'; eventType: "page_view" | "read_progress" | "read_complete";
path: string; path: string;
postSlug?: string; postSlug?: string;
sessionId?: string; sessionId?: string;
@@ -379,7 +385,7 @@ export interface ApiSearchResult {
content: string | null; content: string | null;
category: string | null; category: string | null;
tags: string[] | null; tags: string[] | null;
post_type: 'article' | 'tweet' | null; post_type: "article" | "tweet" | null;
image: string | null; image: string | null;
pinned: boolean | null; pinned: boolean | null;
created_at: string; created_at: string;
@@ -404,10 +410,10 @@ export interface ApiPagedSearchResponse extends ApiPagedResponse<ApiSearchResult
export interface Review { export interface Review {
id: number; id: number;
title: string; title: string;
review_type: 'game' | 'anime' | 'music' | 'book' | 'movie'; review_type: "game" | "anime" | "music" | "book" | "movie";
rating: number; rating: number;
review_date: string; review_date: string;
status: 'published' | 'draft' | 'completed' | 'in-progress' | 'dropped'; status: "published" | "draft" | "completed" | "in-progress" | "dropped";
description: string; description: string;
tags: string; tags: string;
cover: string; cover: string;
@@ -417,59 +423,60 @@ export interface Review {
} }
export type AppFriendLink = UiFriendLink & { export type AppFriendLink = UiFriendLink & {
status: ApiFriendLink['status']; status: ApiFriendLink["status"];
}; };
export const DEFAULT_SITE_SETTINGS: SiteSettings = { export const DEFAULT_SITE_SETTINGS: SiteSettings = {
id: '1', id: "1",
siteName: 'InitCool', siteName: "InitCool",
siteShortName: 'Termi', siteShortName: "Termi",
siteUrl: 'https://init.cool', siteUrl: "https://init.cool",
siteTitle: 'InitCool · 技术笔记与内容档案', siteTitle: "InitCool · 技术笔记与内容档案",
siteDescription: '围绕开发实践、产品观察与长期积累整理的中文内容站。', siteDescription: "一个认真折腾、偶尔整活的小站。",
heroTitle: '欢迎来到 InitCool', heroTitle: "欢迎光临,先随便翻翻",
heroSubtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。', heroSubtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。",
ownerName: 'InitCool', ownerName: "InitCool",
ownerTitle: 'Rust / Go / Python Developer · Builder @ init.cool', ownerTitle: "负责把脑洞拧成页面的人",
ownerBio: 'InitCoolGitHub 用户名 limitcool。坚持不要重复造轮子当前在维护 starter平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。', ownerBio:
location: 'Hong Kong', "一个喜欢把问题拆开、记下、再慢慢拼回去的人。这里不急着自报家门,先看内容,合胃口再认识。",
location: "Hong Kong",
social: { social: {
github: 'https://github.com/limitcool', github: "https://github.com/limitcool",
twitter: '', twitter: "",
email: 'mailto:initcoool@gmail.com', email: "mailto:initcoool@gmail.com",
}, },
techStack: ['Rust', 'Go', 'Python', 'Svelte', 'Astro', 'Loco.rs'], techStack: ["Rust", "Go", "Python", "Svelte", "Astro", "Loco.rs"],
musicEnabled: true, musicEnabled: true,
musicPlaylist: [ musicPlaylist: [
{ {
title: '山中来信', title: "山中来信",
artist: 'InitCool Radio', artist: "InitCool Radio",
album: '站点默认歌单', album: "站点默认歌单",
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
coverImageUrl: coverImageUrl:
'https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80', "https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80",
accentColor: '#2f6b5f', accentColor: "#2f6b5f",
description: '适合文章阅读时循环播放的轻氛围曲。', description: "适合文章阅读时循环播放的轻氛围曲。",
}, },
{ {
title: '风吹松声', title: "风吹松声",
artist: 'InitCool Radio', artist: "InitCool Radio",
album: '站点默认歌单', album: "站点默认歌单",
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3', url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3",
coverImageUrl: coverImageUrl:
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80', "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80",
accentColor: '#8a5b35', accentColor: "#8a5b35",
description: '偏木质感的器乐氛围,适合深夜浏览。', description: "偏木质感的器乐氛围,适合深夜浏览。",
}, },
{ {
title: '夜航小记', title: "夜航小记",
artist: 'InitCool Radio', artist: "InitCool Radio",
album: '站点默认歌单', album: "站点默认歌单",
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3', url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3",
coverImageUrl: coverImageUrl:
'https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80', "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80",
accentColor: '#375a7f', accentColor: "#375a7f",
description: '节奏更明显一点,适合切换阅读状态。', description: "节奏更明显一点,适合切换阅读状态。",
}, },
], ],
ai: { ai: {
@@ -477,16 +484,17 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
}, },
comments: { comments: {
paragraphsEnabled: true, paragraphsEnabled: true,
verificationMode: 'captcha', verificationMode: "captcha",
turnstileEnabled: false, turnstileEnabled: false,
turnstileSiteKey: undefined, turnstileSiteKey: undefined,
}, },
subscriptions: { subscriptions: {
popupEnabled: true, popupEnabled: true,
popupTitle: '订阅更新', popupTitle: "订阅更新",
popupDescription: '有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。', popupDescription:
"有新文章或汇总简报时,通过邮件第一时间收到提醒。需要先确认邮箱,可随时退订。",
popupDelaySeconds: 18, popupDelaySeconds: 18,
verificationMode: 'off', verificationMode: "off",
turnstileEnabled: false, turnstileEnabled: false,
turnstileSiteKey: undefined, turnstileSiteKey: undefined,
webPushEnabled: false, webPushEnabled: false,
@@ -503,7 +511,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
const formatPostDate = (dateString: string) => dateString.slice(0, 10); const formatPostDate = (dateString: string) => dateString.slice(0, 10);
const estimateReadTime = (content: string | null | undefined) => { const estimateReadTime = (content: string | null | undefined) => {
const text = content?.trim() || ''; const text = content?.trim() || "";
const minutes = Math.max(1, Math.ceil(text.length / 300)); const minutes = Math.max(1, Math.ceil(text.length / 300));
return `${minutes} 分钟`; return `${minutes} 分钟`;
}; };
@@ -567,12 +575,12 @@ const normalizeAvatarUrl = (value: string | null | undefined) => {
try { try {
const host = new URL(value).hostname.toLowerCase(); const host = new URL(value).hostname.toLowerCase();
const isReservedExampleHost = const isReservedExampleHost =
host === 'example.com' || host === "example.com" ||
host === 'example.org' || host === "example.org" ||
host === 'example.net' || host === "example.net" ||
host.endsWith('.example.com') || host.endsWith(".example.com") ||
host.endsWith('.example.org') || host.endsWith(".example.org") ||
host.endsWith('.example.net'); host.endsWith(".example.net");
return isReservedExampleHost ? undefined : value; return isReservedExampleHost ? undefined : value;
} catch { } catch {
@@ -595,15 +603,16 @@ const normalizeFriendLink = (friendLink: ApiFriendLink): AppFriendLink => ({
const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => { const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
const commentVerificationMode = normalizeVerificationMode( const commentVerificationMode = normalizeVerificationMode(
settings.comment_verification_mode, settings.comment_verification_mode,
settings.comment_turnstile_enabled ? 'turnstile' : 'captcha', settings.comment_turnstile_enabled ? "turnstile" : "captcha",
); );
const subscriptionVerificationMode = normalizeVerificationMode( const subscriptionVerificationMode = normalizeVerificationMode(
settings.subscription_verification_mode, settings.subscription_verification_mode,
settings.subscription_turnstile_enabled ? 'turnstile' : 'off', settings.subscription_turnstile_enabled ? "turnstile" : "off",
); );
const musicEnabled = settings.music_enabled ?? true; const musicEnabled = settings.music_enabled ?? true;
const normalizedMusicPlaylist = const normalizedMusicPlaylist = settings.music_playlist?.filter(
settings.music_playlist?.filter((item) => item?.title?.trim() && item?.url?.trim())?.length (item) => item?.title?.trim() && item?.url?.trim(),
)?.length
? settings.music_playlist ? settings.music_playlist
.filter((item) => item.title.trim() && item.url.trim()) .filter((item) => item.title.trim() && item.url.trim())
.map((item) => ({ .map((item) => ({
@@ -620,10 +629,12 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
return { return {
id: String(settings.id), id: String(settings.id),
siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName, siteName: settings.site_name || DEFAULT_SITE_SETTINGS.siteName,
siteShortName: settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName, siteShortName:
settings.site_short_name || DEFAULT_SITE_SETTINGS.siteShortName,
siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl, siteUrl: settings.site_url || DEFAULT_SITE_SETTINGS.siteUrl,
siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle, siteTitle: settings.site_title || DEFAULT_SITE_SETTINGS.siteTitle,
siteDescription: settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription, siteDescription:
settings.site_description || DEFAULT_SITE_SETTINGS.siteDescription,
heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle, heroTitle: settings.hero_title || DEFAULT_SITE_SETTINGS.heroTitle,
heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle, heroSubtitle: settings.hero_subtitle || DEFAULT_SITE_SETTINGS.heroSubtitle,
ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName, ownerName: settings.owner_name || DEFAULT_SITE_SETTINGS.ownerName,
@@ -636,7 +647,9 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter, twitter: settings.social_twitter || DEFAULT_SITE_SETTINGS.social.twitter,
email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email, email: settings.social_email || DEFAULT_SITE_SETTINGS.social.email,
}, },
techStack: settings.tech_stack?.length ? settings.tech_stack : DEFAULT_SITE_SETTINGS.techStack, techStack: settings.tech_stack?.length
? settings.tech_stack
: DEFAULT_SITE_SETTINGS.techStack,
musicEnabled, musicEnabled,
musicPlaylist: musicEnabled ? normalizedMusicPlaylist : [], musicPlaylist: musicEnabled ? normalizedMusicPlaylist : [],
ai: { ai: {
@@ -645,15 +658,19 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
comments: { comments: {
verificationMode: commentVerificationMode, verificationMode: commentVerificationMode,
paragraphsEnabled: settings.paragraph_comments_enabled ?? true, paragraphsEnabled: settings.paragraph_comments_enabled ?? true,
turnstileEnabled: commentVerificationMode === 'turnstile', turnstileEnabled: commentVerificationMode === "turnstile",
turnstileSiteKey: turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined, settings.turnstile_site_key ||
resolvePublicCommentTurnstileSiteKey() ||
undefined,
}, },
subscriptions: { subscriptions: {
popupEnabled: popupEnabled:
settings.subscription_popup_enabled ?? DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled, settings.subscription_popup_enabled ??
DEFAULT_SITE_SETTINGS.subscriptions.popupEnabled,
popupTitle: popupTitle:
settings.subscription_popup_title || DEFAULT_SITE_SETTINGS.subscriptions.popupTitle, settings.subscription_popup_title ||
DEFAULT_SITE_SETTINGS.subscriptions.popupTitle,
popupDescription: popupDescription:
settings.subscription_popup_description || settings.subscription_popup_description ||
DEFAULT_SITE_SETTINGS.subscriptions.popupDescription, DEFAULT_SITE_SETTINGS.subscriptions.popupDescription,
@@ -661,9 +678,11 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
settings.subscription_popup_delay_seconds ?? settings.subscription_popup_delay_seconds ??
DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds, DEFAULT_SITE_SETTINGS.subscriptions.popupDelaySeconds,
verificationMode: subscriptionVerificationMode, verificationMode: subscriptionVerificationMode,
turnstileEnabled: subscriptionVerificationMode === 'turnstile', turnstileEnabled: subscriptionVerificationMode === "turnstile",
turnstileSiteKey: turnstileSiteKey:
settings.turnstile_site_key || resolvePublicCommentTurnstileSiteKey() || undefined, settings.turnstile_site_key ||
resolvePublicCommentTurnstileSiteKey() ||
undefined,
webPushEnabled: Boolean(settings.web_push_enabled), webPushEnabled: Boolean(settings.web_push_enabled),
webPushVapidPublicKey: webPushVapidPublicKey:
settings.web_push_vapid_public_key || settings.web_push_vapid_public_key ||
@@ -680,7 +699,7 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
}; };
const normalizeContentOverview = ( const normalizeContentOverview = (
overview: ApiHomePagePayload['content_overview'] | undefined, overview: ApiHomePagePayload["content_overview"] | undefined,
): ContentOverview => ({ ): ContentOverview => ({
totalPageViews: overview?.total_page_views ?? 0, totalPageViews: overview?.total_page_views ?? 0,
pageViewsLast24h: overview?.page_views_last_24h ?? 0, pageViewsLast24h: overview?.page_views_last_24h ?? 0,
@@ -692,9 +711,9 @@ const normalizeContentOverview = (
}); });
const CONTENT_WINDOW_META = [ const CONTENT_WINDOW_META = [
{ key: '24h', label: '24h', days: 1 }, { key: "24h", label: "24h", days: 1 },
{ key: '7d', label: '7d', days: 7 }, { key: "7d", label: "7d", days: 7 },
{ key: '30d', label: '30d', days: 30 }, { key: "30d", label: "30d", days: 30 },
] as const; ] as const;
const normalizePopularPost = ( const normalizePopularPost = (
@@ -718,9 +737,9 @@ const normalizePopularPost = (
}); });
const normalizeContentRanges = ( const normalizeContentRanges = (
ranges: ApiHomePagePayload['content_ranges'] | undefined, ranges: ApiHomePagePayload["content_ranges"] | undefined,
overview: ApiHomePagePayload['content_overview'] | undefined, overview: ApiHomePagePayload["content_overview"] | undefined,
popularPosts: ApiHomePagePayload['popular_posts'] | undefined, popularPosts: ApiHomePagePayload["popular_posts"] | undefined,
postsBySlug: Map<string, UiPost>, postsBySlug: Map<string, UiPost>,
): ContentWindowHighlight[] => { ): ContentWindowHighlight[] => {
const normalizedRanges = new Map( const normalizedRanges = new Map(
@@ -749,7 +768,7 @@ const normalizeContentRanges = (
return existing; return existing;
} }
if (meta.key === '7d') { if (meta.key === "7d") {
return { return {
key: meta.key, key: meta.key,
label: meta.label, label: meta.label,
@@ -758,13 +777,16 @@ const normalizeContentRanges = (
pageViews: overview?.page_views_last_7d ?? 0, pageViews: overview?.page_views_last_7d ?? 0,
readCompletes: overview?.read_completes_last_7d ?? 0, readCompletes: overview?.read_completes_last_7d ?? 0,
avgReadProgress: overview?.avg_read_progress_last_7d ?? 0, avgReadProgress: overview?.avg_read_progress_last_7d ?? 0,
avgReadDurationMs: overview?.avg_read_duration_ms_last_7d ?? undefined, avgReadDurationMs:
overview?.avg_read_duration_ms_last_7d ?? undefined,
}, },
popularPosts: (popularPosts ?? []).map((item) => normalizePopularPost(item, postsBySlug)), popularPosts: (popularPosts ?? []).map((item) =>
normalizePopularPost(item, postsBySlug),
),
}; };
} }
if (meta.key === '24h') { if (meta.key === "24h") {
return { return {
key: meta.key, key: meta.key,
label: meta.label, label: meta.label,
@@ -805,14 +827,16 @@ class ApiClient {
const response = await fetch(`${this.baseUrl}${path}`, { const response = await fetch(`${this.baseUrl}${path}`, {
...options, ...options,
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
...options?.headers, ...options?.headers,
}, },
}); });
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => ''); const errorText = await response.text().catch(() => "");
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`); throw new Error(
errorText || `API error: ${response.status} ${response.statusText}`,
);
} }
if (response.status === 204) { if (response.status === 204) {
@@ -823,7 +847,7 @@ class ApiClient {
} }
async getRawPosts(): Promise<ApiPost[]> { async getRawPosts(): Promise<ApiPost[]> {
return this.fetch<ApiPost[]>('/posts'); return this.fetch<ApiPost[]>("/posts");
} }
async getPosts(): Promise<UiPost[]> { async getPosts(): Promise<UiPost[]> {
@@ -850,16 +874,18 @@ class ApiClient {
sortOrder: string; sortOrder: string;
}> { }> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (options?.page) params.set('page', String(options.page)); if (options?.page) params.set("page", String(options.page));
if (options?.pageSize) params.set('page_size', String(options.pageSize)); if (options?.pageSize) params.set("page_size", String(options.pageSize));
if (options?.search) params.set('search', options.search); if (options?.search) params.set("search", options.search);
if (options?.category) params.set('category', options.category); if (options?.category) params.set("category", options.category);
if (options?.tag) params.set('tag', options.tag); if (options?.tag) params.set("tag", options.tag);
if (options?.postType) params.set('type', options.postType); if (options?.postType) params.set("type", options.postType);
if (options?.sortBy) params.set('sort_by', options.sortBy); if (options?.sortBy) params.set("sort_by", options.sortBy);
if (options?.sortOrder) params.set('sort_order', options.sortOrder); if (options?.sortOrder) params.set("sort_order", options.sortOrder);
const payload = await this.fetch<ApiPagedResponse<ApiPost>>(`/posts/page?${params.toString()}`); const payload = await this.fetch<ApiPagedResponse<ApiPost>>(
`/posts/page?${params.toString()}`,
);
return { return {
items: payload.items.map(normalizePost), items: payload.items.map(normalizePost),
page: payload.page, page: payload.page,
@@ -877,27 +903,32 @@ class ApiClient {
} }
async getPostBySlug(slug: string): Promise<UiPost | null> { async getPostBySlug(slug: string): Promise<UiPost | null> {
const response = await fetch(`${this.baseUrl}/posts/slug/${encodeURIComponent(slug)}`, { const response = await fetch(
`${this.baseUrl}/posts/slug/${encodeURIComponent(slug)}`,
{
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
}); },
);
if (response.status === 404) { if (response.status === 404) {
return null; return null;
} }
if (!response.ok) { if (!response.ok) {
const errorText = await response.text().catch(() => ''); const errorText = await response.text().catch(() => "");
throw new Error(errorText || `API error: ${response.status} ${response.statusText}`); throw new Error(
errorText || `API error: ${response.status} ${response.statusText}`,
);
} }
return normalizePost((await response.json()) as ApiPost); return normalizePost((await response.json()) as ApiPost);
} }
async recordContentEvent(input: ContentAnalyticsInput): Promise<void> { async recordContentEvent(input: ContentAnalyticsInput): Promise<void> {
await this.fetch<{ recorded: boolean }>('/analytics/content', { await this.fetch<{ recorded: boolean }>("/analytics/content", {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
event_type: input.eventType, event_type: input.eventType,
path: input.path, path: input.path,
@@ -908,38 +939,42 @@ class ApiClient {
metadata: input.metadata, metadata: input.metadata,
referrer: input.referrer, referrer: input.referrer,
}), }),
}) });
} }
async getComments( async getComments(
postSlug: string, postSlug: string,
options?: { options?: {
approved?: boolean; approved?: boolean;
scope?: 'article' | 'paragraph'; scope?: "article" | "paragraph";
paragraphKey?: string; paragraphKey?: string;
} },
): Promise<Comment[]> { ): Promise<Comment[]> {
const params = new URLSearchParams({ post_slug: postSlug }); const params = new URLSearchParams({ post_slug: postSlug });
if (options?.approved !== undefined) { if (options?.approved !== undefined) {
params.set('approved', String(options.approved)); params.set("approved", String(options.approved));
} }
if (options?.scope) { if (options?.scope) {
params.set('scope', options.scope); params.set("scope", options.scope);
} }
if (options?.paragraphKey) { if (options?.paragraphKey) {
params.set('paragraph_key', options.paragraphKey); params.set("paragraph_key", options.paragraphKey);
} }
return this.fetch<Comment[]>(`/comments?${params.toString()}`); return this.fetch<Comment[]>(`/comments?${params.toString()}`);
} }
async getParagraphCommentSummary(postSlug: string): Promise<ParagraphCommentSummary[]> { async getParagraphCommentSummary(
postSlug: string,
): Promise<ParagraphCommentSummary[]> {
const params = new URLSearchParams({ post_slug: postSlug }); const params = new URLSearchParams({ post_slug: postSlug });
return this.fetch<ParagraphCommentSummary[]>(`/comments/paragraphs/summary?${params.toString()}`); return this.fetch<ParagraphCommentSummary[]>(
`/comments/paragraphs/summary?${params.toString()}`,
);
} }
async createComment(comment: CreateCommentInput): Promise<Comment> { async createComment(comment: CreateCommentInput): Promise<Comment> {
return this.fetch<Comment>('/comments', { return this.fetch<Comment>("/comments", {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
postSlug: comment.postSlug, postSlug: comment.postSlug,
nickname: comment.nickname, nickname: comment.nickname,
@@ -959,11 +994,11 @@ class ApiClient {
} }
async getCommentCaptcha(): Promise<CommentCaptchaChallenge> { async getCommentCaptcha(): Promise<CommentCaptchaChallenge> {
return this.fetch<CommentCaptchaChallenge>('/comments/captcha'); return this.fetch<CommentCaptchaChallenge>("/comments/captcha");
} }
async getReviews(): Promise<Review[]> { async getReviews(): Promise<Review[]> {
return this.fetch<Review[]>('/reviews'); return this.fetch<Review[]>("/reviews");
} }
async getReview(id: number): Promise<Review> { async getReview(id: number): Promise<Review> {
@@ -971,7 +1006,7 @@ class ApiClient {
} }
async getRawFriendLinks(): Promise<ApiFriendLink[]> { async getRawFriendLinks(): Promise<ApiFriendLink[]> {
return this.fetch<ApiFriendLink[]>('/friend_links'); return this.fetch<ApiFriendLink[]>("/friend_links");
} }
async getFriendLinks(): Promise<AppFriendLink[]> { async getFriendLinks(): Promise<AppFriendLink[]> {
@@ -979,9 +1014,11 @@ class ApiClient {
return friendLinks.map(normalizeFriendLink); return friendLinks.map(normalizeFriendLink);
} }
async createFriendLink(friendLink: CreateFriendLinkInput): Promise<ApiFriendLink> { async createFriendLink(
return this.fetch<ApiFriendLink>('/friend_links', { friendLink: CreateFriendLinkInput,
method: 'POST', ): Promise<ApiFriendLink> {
return this.fetch<ApiFriendLink>("/friend_links", {
method: "POST",
body: JSON.stringify(friendLink), body: JSON.stringify(friendLink),
}); });
} }
@@ -994,8 +1031,8 @@ class ApiClient {
captchaToken?: string; captchaToken?: string;
captchaAnswer?: string; captchaAnswer?: string;
}): Promise<PublicSubscriptionResponse> { }): Promise<PublicSubscriptionResponse> {
return this.fetch<PublicSubscriptionResponse>('/subscriptions', { return this.fetch<PublicSubscriptionResponse>("/subscriptions", {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
email: input.email, email: input.email,
displayName: input.displayName, displayName: input.displayName,
@@ -1007,14 +1044,21 @@ class ApiClient {
}); });
} }
async confirmSubscription(token: string): Promise<PublicSubscriptionManageResponse> { async confirmSubscription(
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/confirm', { token: string,
method: 'POST', ): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>(
"/subscriptions/confirm",
{
method: "POST",
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
}); },
);
} }
async getManagedSubscription(token: string): Promise<PublicSubscriptionManageResponse> { async getManagedSubscription(
token: string,
): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>( return this.fetch<PublicSubscriptionManageResponse>(
`/subscriptions/manage?token=${encodeURIComponent(token)}`, `/subscriptions/manage?token=${encodeURIComponent(token)}`,
); );
@@ -1026,26 +1070,34 @@ class ApiClient {
status?: string | null; status?: string | null;
filters?: Record<string, unknown> | null; filters?: Record<string, unknown> | null;
}): Promise<PublicSubscriptionManageResponse> { }): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/manage', { return this.fetch<PublicSubscriptionManageResponse>(
method: 'PATCH', "/subscriptions/manage",
{
method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
token: input.token, token: input.token,
displayName: input.displayName, displayName: input.displayName,
status: input.status, status: input.status,
filters: input.filters, filters: input.filters,
}), }),
}); },
);
} }
async unsubscribeSubscription(token: string): Promise<PublicSubscriptionManageResponse> { async unsubscribeSubscription(
return this.fetch<PublicSubscriptionManageResponse>('/subscriptions/unsubscribe', { token: string,
method: 'POST', ): Promise<PublicSubscriptionManageResponse> {
return this.fetch<PublicSubscriptionManageResponse>(
"/subscriptions/unsubscribe",
{
method: "POST",
body: JSON.stringify({ token }), body: JSON.stringify({ token }),
}); },
);
} }
async getRawTags(): Promise<ApiTag[]> { async getRawTags(): Promise<ApiTag[]> {
return this.fetch<ApiTag[]>('/tags'); return this.fetch<ApiTag[]>("/tags");
} }
async getTags(): Promise<UiTag[]> { async getTags(): Promise<UiTag[]> {
@@ -1054,7 +1106,7 @@ class ApiClient {
} }
async getRawSiteSettings(): Promise<ApiSiteSettings> { async getRawSiteSettings(): Promise<ApiSiteSettings> {
return this.fetch<ApiSiteSettings>('/site_settings'); return this.fetch<ApiSiteSettings>("/site_settings");
} }
async getSiteSettings(): Promise<SiteSettings> { async getSiteSettings(): Promise<SiteSettings> {
@@ -1072,7 +1124,7 @@ class ApiClient {
contentRanges: ContentWindowHighlight[]; contentRanges: ContentWindowHighlight[];
popularPosts: PopularPostHighlight[]; popularPosts: PopularPostHighlight[];
}> { }> {
const payload = await this.fetch<ApiHomePagePayload>('/site_settings/home'); const payload = await this.fetch<ApiHomePagePayload>("/site_settings/home");
const posts = (payload.posts ?? []).map(normalizePost); const posts = (payload.posts ?? []).map(normalizePost);
const postsBySlug = new Map(posts.map((post) => [post.slug, post])); const postsBySlug = new Map(posts.map((post) => [post.slug, post]));
const popularPosts = (payload.popular_posts ?? []).map((item) => const popularPosts = (payload.popular_posts ?? []).map((item) =>
@@ -1097,20 +1149,22 @@ class ApiClient {
} }
async getCategories(): Promise<UiCategory[]> { async getCategories(): Promise<UiCategory[]> {
const categories = await this.fetch<ApiCategory[]>('/categories'); const categories = await this.fetch<ApiCategory[]>("/categories");
return categories.map(normalizeCategory); return categories.map(normalizeCategory);
} }
async getPostsByCategory(category: string): Promise<UiPost[]> { async getPostsByCategory(category: string): Promise<UiPost[]> {
const posts = await this.getPosts(); const posts = await this.getPosts();
return posts.filter(post => post.category?.toLowerCase() === category.toLowerCase()); return posts.filter(
(post) => post.category?.toLowerCase() === category.toLowerCase(),
);
} }
async getPostsByTag(tag: string): Promise<UiPost[]> { async getPostsByTag(tag: string): Promise<UiPost[]> {
const posts = await this.getPosts(); const posts = await this.getPosts();
const normalizedTag = normalizeTagToken(tag); const normalizedTag = normalizeTagToken(tag);
return posts.filter(post => return posts.filter((post) =>
post.tags?.some(item => normalizeTagToken(item) === normalizedTag) post.tags?.some((item) => normalizeTagToken(item) === normalizedTag),
); );
} }
@@ -1119,18 +1173,20 @@ class ApiClient {
q: query, q: query,
limit: String(limit), limit: String(limit),
}); });
const results = await this.fetch<ApiSearchResult[]>(`/search?${params.toString()}`); const results = await this.fetch<ApiSearchResult[]>(
`/search?${params.toString()}`,
);
return results.map(result => return results.map((result) =>
normalizePost({ normalizePost({
id: result.id, id: result.id,
title: result.title || 'Untitled', title: result.title || "Untitled",
slug: result.slug, slug: result.slug,
description: result.description || '', description: result.description || "",
content: result.content || '', content: result.content || "",
category: result.category || '', category: result.category || "",
tags: result.tags ?? [], tags: result.tags ?? [],
post_type: result.post_type || 'article', post_type: result.post_type || "article",
image: result.image, image: result.image,
images: null, images: null,
pinned: result.pinned ?? false, pinned: result.pinned ?? false,
@@ -1145,7 +1201,7 @@ class ApiClient {
redirect_to: null, redirect_to: null,
created_at: result.created_at, created_at: result.created_at,
updated_at: result.updated_at, updated_at: result.updated_at,
}) }),
); );
} }
@@ -1169,27 +1225,29 @@ class ApiClient {
sortOrder: string; sortOrder: string;
}> { }> {
const params = new URLSearchParams({ q: options.query }); const params = new URLSearchParams({ q: options.query });
if (options.page) params.set('page', String(options.page)); if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set('page_size', String(options.pageSize)); if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.category) params.set('category', options.category); if (options.category) params.set("category", options.category);
if (options.tag) params.set('tag', options.tag); if (options.tag) params.set("tag", options.tag);
if (options.postType) params.set('type', options.postType); if (options.postType) params.set("type", options.postType);
if (options.sortBy) params.set('sort_by', options.sortBy); if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortOrder) params.set('sort_order', options.sortOrder); if (options.sortOrder) params.set("sort_order", options.sortOrder);
const payload = await this.fetch<ApiPagedSearchResponse>(`/search/page?${params.toString()}`); const payload = await this.fetch<ApiPagedSearchResponse>(
`/search/page?${params.toString()}`,
);
return { return {
query: payload.query, query: payload.query,
items: payload.items.map((result) => items: payload.items.map((result) =>
normalizePost({ normalizePost({
id: result.id, id: result.id,
title: result.title || 'Untitled', title: result.title || "Untitled",
slug: result.slug, slug: result.slug,
description: result.description || '', description: result.description || "",
content: result.content || '', content: result.content || "",
category: result.category || '', category: result.category || "",
tags: result.tags ?? [], tags: result.tags ?? [],
post_type: result.post_type || 'article', post_type: result.post_type || "article",
image: result.image, image: result.image,
images: null, images: null,
pinned: result.pinned ?? false, pinned: result.pinned ?? false,
@@ -1216,15 +1274,20 @@ class ApiClient {
} }
async askAi(question: string): Promise<AiAskResponse> { async askAi(question: string): Promise<AiAskResponse> {
return this.fetch<AiAskResponse>('/ai/ask', { return this.fetch<AiAskResponse>("/ai/ask", {
method: 'POST', method: "POST",
body: JSON.stringify({ question }), body: JSON.stringify({ question }),
}); });
} }
} }
export function createApiClient(options?: { baseUrl?: string; requestUrl?: string | URL }) { export function createApiClient(options?: {
return new ApiClient(options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl)); baseUrl?: string;
requestUrl?: string | URL;
}) {
return new ApiClient(
options?.baseUrl ?? resolveInternalApiBaseUrl(options?.requestUrl),
);
} }
export const api = createApiClient(); export const api = createApiClient();

View File

@@ -40,7 +40,7 @@ export interface TerminalConfig {
description: string; description: string;
date: string; date: string;
readTime: string; readTime: string;
type: 'article' | 'tweet'; type: "article" | "tweet";
tags: string[]; tags: string[];
link: string; link: string;
}; };
@@ -83,14 +83,14 @@ export interface TerminalConfig {
} }
export const terminalConfig: TerminalConfig = { export const terminalConfig: TerminalConfig = {
defaultCategory: 'blog', defaultCategory: "blog",
welcomeMessage: '欢迎来到 InitCool', welcomeMessage: "欢迎来到 InitCool",
prompt: { prompt: {
prefix: 'user@blog', prefix: "user@blog",
separator: ':', separator: ":",
path: '~/', path: "~/",
suffix: '$', suffix: "$",
mobile: '~$' mobile: "~$",
}, },
asciiArt: ` asciiArt: `
I N N I TTTTT CCCC OOO OOO L I N N I TTTTT CCCC OOO OOO L
@@ -98,70 +98,70 @@ I NN N I T C O O O O L
I N N N I T C O O O O L I N N N I T C O O O O L
I N NN I T C O O O O L I N NN I T C O O O O L
I N N I T CCCC OOO OOO LLLLL`, I N N I T CCCC OOO OOO LLLLL`,
title: '~/blog', title: "~/blog",
welcome: { welcome: {
title: '欢迎来到 InitCool', title: "欢迎来到 InitCool",
subtitle: '记录开发实践、产品观察与长期积累,分享清晰、耐读、可回看的内容。' subtitle: "这里像个边修边长的工具箱,偶尔掉装备,偶尔掉灵感,先逛再说。",
}, },
navLinks: [ navLinks: [
{ icon: 'fa-file-code', text: '文章', href: '/articles' }, { icon: "fa-file-code", text: "文章", href: "/articles" },
{ icon: 'fa-folder', text: '分类', href: '/categories' }, { icon: "fa-folder", text: "分类", href: "/categories" },
{ icon: 'fa-tags', text: '标签', href: '/tags' }, { icon: "fa-tags", text: "标签", href: "/tags" },
{ icon: 'fa-stream', text: '时间轴', href: '/timeline' }, { icon: "fa-stream", text: "时间轴", href: "/timeline" },
{ icon: 'fa-star', text: '评价', href: '/reviews' }, { icon: "fa-star", text: "评价", href: "/reviews" },
{ icon: 'fa-link', text: '友链', href: '/friends' }, { icon: "fa-link", text: "友链", href: "/friends" },
{ icon: 'fa-user-secret', text: '关于', href: '/about' } { icon: "fa-user-secret", text: "关于", href: "/about" },
], ],
categories: { categories: {
blog: { blog: {
title: '博客', title: "博客",
description: '我的个人博客文章', description: "我的个人博客文章",
items: [ items: [
{ {
command: 'help', command: "help",
description: '显示帮助信息', description: "显示帮助信息",
shortDesc: '显示帮助信息' shortDesc: "显示帮助信息",
} },
] ],
} },
}, },
postTypes: { postTypes: {
article: { color: '#00ff9d', label: '博客文章' }, article: { color: "#00ff9d", label: "博客文章" },
tweet: { color: '#00b8ff', label: '推文' } tweet: { color: "#00b8ff", label: "推文" },
}, },
socialLinks: { socialLinks: {
github: '', github: "",
twitter: '', twitter: "",
email: '' email: "",
}, },
tools: [ tools: [
{ icon: 'fa-sitemap', href: '/sitemap.xml', title: '站点地图' }, { icon: "fa-sitemap", href: "/sitemap.xml", title: "站点地图" },
{ icon: 'fa-rss', href: '/rss.xml', title: 'RSS订阅' } { icon: "fa-rss", href: "/rss.xml", title: "RSS订阅" },
], ],
search: { search: {
placeholders: { placeholders: {
default: "'关键词' 文章 / 标签 / 分类", default: "'关键词' 文章 / 标签 / 分类",
small: "搜索...", small: "搜索...",
medium: "搜索文章..." medium: "搜索文章...",
}, },
promptText: "搜索", promptText: "搜索",
emptyResultText: "输入关键词搜索文章" emptyResultText: "输入关键词搜索文章",
}, },
terminal: { terminal: {
defaultWindowTitle: 'user@terminal: ~/blog', defaultWindowTitle: "user@terminal: ~/blog",
controls: { controls: {
colors: { colors: {
close: '#ff5f56', close: "#ff5f56",
minimize: '#ffbd2e', minimize: "#ffbd2e",
expand: '#27c93f' expand: "#27c93f",
} },
}, },
animation: { animation: {
glowDuration: '4s' glowDuration: "4s",
} },
}, },
branding: { branding: {
name: 'InitCool', name: "InitCool",
shortName: 'Termi' shortName: "Termi",
} },
}; };

View File

@@ -300,6 +300,7 @@ export const messages = {
enterQuestion: '先输入一个问题。', enterQuestion: '先输入一个问题。',
cacheRestored: '已从当前会话缓存中恢复回答。', cacheRestored: '已从当前会话缓存中恢复回答。',
connecting: '正在建立流式连接,请稍候...', connecting: '正在建立流式连接,请稍候...',
connectingShort: '请稍候...',
processing: '正在处理请求...', processing: '正在处理请求...',
complete: '回答已生成。', complete: '回答已生成。',
streamFailed: '流式请求失败', streamFailed: '流式请求失败',
@@ -749,6 +750,7 @@ export const messages = {
enterQuestion: 'Enter a question first.', enterQuestion: 'Enter a question first.',
cacheRestored: 'Restored the answer from the current session cache.', cacheRestored: 'Restored the answer from the current session cache.',
connecting: 'Opening stream connection...', connecting: 'Opening stream connection...',
connectingShort: 'Please wait...',
processing: 'Processing request...', processing: 'Processing request...',
complete: 'Answer generated.', complete: 'Answer generated.',
streamFailed: 'Streaming request failed', streamFailed: 'Streaming request failed',

View File

@@ -437,7 +437,7 @@ const breadcrumbJsonLd = {
</div> </div>
<section class="grid items-start gap-5 xl:grid-cols-[minmax(0,1.62fr)_minmax(16.5rem,0.78fr)] 2xl:grid-cols-[minmax(0,1.8fr)_minmax(17rem,0.82fr)]"> <section class="grid items-start gap-5 xl:grid-cols-[minmax(0,1.62fr)_minmax(16.5rem,0.78fr)] 2xl:grid-cols-[minmax(0,1.8fr)_minmax(17rem,0.82fr)]">
<div class="relative overflow-hidden rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(var(--secondary-rgb),0.05)_46%,rgba(var(--bg-rgb),0.92))] p-5 sm:p-6"> <div class="relative overflow-hidden rounded-[28px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_98%,transparent),color-mix(in_oklab,var(--header-bg)_92%,transparent)),linear-gradient(135deg,rgba(var(--primary-rgb),0.08),rgba(var(--secondary-rgb),0.04)_46%,transparent)] p-5 shadow-[0_16px_40px_rgba(15,23,42,0.08)] sm:p-6">
<div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div> <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 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="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
@@ -458,7 +458,7 @@ const breadcrumbJsonLd = {
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.digestDescription}</p> <p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.digestDescription}</p>
</div> </div>
<div class="rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]"> <div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
<div class="space-y-3"> <div class="space-y-3">
{articlePreviewParagraphs.map((paragraph) => ( {articlePreviewParagraphs.map((paragraph) => (
<p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p> <p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p>
@@ -500,7 +500,7 @@ const breadcrumbJsonLd = {
</div> </div>
<div class="grid gap-3 xl:grid-cols-[minmax(0,1.18fr)_minmax(13rem,0.82fr)]"> <div class="grid gap-3 xl:grid-cols-[minmax(0,1.18fr)_minmax(13rem,0.82fr)]">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3"> <div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_90%,transparent))] px-4 py-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1"> <div class="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]"> <p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
@@ -547,7 +547,7 @@ const breadcrumbJsonLd = {
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1"> <div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
{digestStats.map((item) => ( {digestStats.map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 px-4 py-3"> <div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_97%,transparent)] px-4 py-3">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{item.label}</div> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{item.label}</div>
<div class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div> <div class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
</div> </div>
@@ -558,7 +558,7 @@ const breadcrumbJsonLd = {
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]"> <div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]"> <h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.sourceTitle} {articleCopy.sourceTitle}
</h3> </h3>
@@ -601,13 +601,13 @@ const breadcrumbJsonLd = {
</div> </div>
{articleHighlights.length > 0 && ( {articleHighlights.length > 0 && (
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]"> <div class="rounded-[24px] border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_97%,transparent),color-mix(in_oklab,var(--header-bg)_91%,transparent))] p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]"> <h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.highlightsTitle} {articleCopy.highlightsTitle}
</h3> </h3>
<div class="mt-4 space-y-3"> <div class="mt-4 space-y-3">
{articleHighlights.map((item, index) => ( {articleHighlights.map((item, index) => (
<div class="flex items-start gap-3 rounded-2xl border border-[var(--border-color)]/80 bg-[var(--bg)]/58 px-4 py-3"> <div class="flex items-start gap-3 rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--terminal-bg)_95%,transparent)] px-4 py-3">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]"> <span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]">
{index + 1} {index + 1}
</span> </span>
@@ -828,7 +828,7 @@ const breadcrumbJsonLd = {
> >
<button <button
type="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)]" class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-copy" data-article-floating-action="digest-copy"
title={articleCopy.copySummary} title={articleCopy.copySummary}
aria-label={articleCopy.copySummary} aria-label={articleCopy.copySummary}
@@ -837,7 +837,7 @@ const breadcrumbJsonLd = {
</button> </button>
<button <button
type="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)]" class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="digest-share" data-article-floating-action="digest-share"
title={articleCopy.shareSummary} title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary} aria-label={articleCopy.shareSummary}
@@ -847,7 +847,7 @@ const breadcrumbJsonLd = {
{wechatShareQrEnabled && wechatShareQrSvg && ( {wechatShareQrEnabled && wechatShareQrSvg && (
<button <button
type="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)]" class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="wechat-qr" data-article-floating-action="wechat-qr"
title={articleCopy.shareToWeChat} title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat} aria-label={articleCopy.shareToWeChat}
@@ -857,7 +857,7 @@ const breadcrumbJsonLd = {
)} )}
<button <button
type="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)]" class="article-floating-action-btn flex h-11 w-full items-center justify-center rounded-2xl transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
data-article-floating-action="permalink-copy" data-article-floating-action="permalink-copy"
title={t('common.copyPermalink')} title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')} aria-label={t('common.copyPermalink')}
@@ -903,19 +903,19 @@ const breadcrumbJsonLd = {
</div> </div>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]"> <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="mx-auto w-full max-w-[260px] rounded-[28px] border border-[color:color-mix(in_oklab,var(--primary)_12%,var(--border-color))] bg-[linear-gradient(180deg,color-mix(in_oklab,var(--terminal-bg)_94%,white_6%),color-mix(in_oklab,var(--header-bg)_92%,white_4%))] p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div> <div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5"> <div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--header-bg)_88%,transparent)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.canonical} {articleCopy.canonical}
</div> </div>
<p class="mt-3 break-all font-mono text-sm leading-7 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>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5"> <div class="rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] bg-[color-mix(in_oklab,var(--header-bg)_88%,transparent)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]"> <div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.digestTitle} {articleCopy.digestTitle}
</div> </div>

View File

@@ -4,6 +4,7 @@ import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro'; import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.astro'; import SharePanel from '../../components/seo/SharePanel.astro';
import CommandPrompt from '../../components/ui/CommandPrompt.astro'; import CommandPrompt from '../../components/ui/CommandPrompt.astro';
import TerminalWindow from '../../components/ui/TerminalWindow.astro';
import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client'; import { api, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl } from '../../lib/api/client';
import { getI18n } from '../../lib/i18n'; import { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo'; import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
@@ -70,14 +71,16 @@ const sharePanelCopy = isEnglish
description: description:
'Share the sites AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.', 'Share the sites AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.',
examples: 'Prompts', examples: 'Prompts',
ai: 'AI', status: 'Status',
statusValue: aiEnabled ? 'Ready' : 'Idle',
} }
: { : {
badge: '问答入口', badge: '问答入口',
title: '分享问答页', title: '把问答入口甩出去',
description: '把这个问答页分享给需要快速检索站内内容的人。', description: '有人想少走弯路时,把这页递过去就行。',
examples: '示例问题', examples: '示例问题',
ai: 'AI', status: '状态',
statusValue: aiEnabled ? '随时开问' : '暂时休息',
}; };
const askHighlights = buildDiscoveryHighlights([ const askHighlights = buildDiscoveryHighlights([
t('ask.subtitle'), t('ask.subtitle'),
@@ -107,25 +110,38 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
jsonLd={compactJsonLd([...askJsonLd, askFaqJsonLd])} jsonLd={compactJsonLd([...askJsonLd, askFaqJsonLd])}
> >
<PageViewTracker pageType="ask" entityId="ask" /> <PageViewTracker pageType="ask" entityId="ask" />
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> <section class="mx-auto max-w-[1660px] px-4 py-8 sm:px-6 lg:px-8">
<div class="rounded-3xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_28px_90px_rgba(15,23,42,0.08)] overflow-hidden"> <TerminalWindow title="~/ask" class="w-full">
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4"> <div class="px-4 pb-2">
<div> <div class="terminal-panel ml-4 mt-4 space-y-6">
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">{t('ask.terminalLabel')}</div> <div class="flex flex-wrap items-start justify-between gap-4">
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1> <div class="space-y-3">
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p> <span class="terminal-kicker">
</div> <i class="fas fa-sparkles"></i>
<div class:list={[ {t('ask.terminalLabel')}
'rounded-full border px-3 py-1 text-xs font-mono', </span>
aiEnabled <div class="space-y-2">
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' <h1 class="text-2xl font-bold text-[var(--title-color)] sm:text-3xl">{t('ask.title')}</h1>
: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-300' <p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)] sm:text-base">
]}> {t('ask.subtitle')}
{aiEnabled ? t('common.featureOn') : t('common.featureOff')} </p>
</div> </div>
</div> </div>
<div class="px-5 pt-6"> <span class:list={[
'terminal-stat-pill px-3 py-1.5 text-xs font-mono',
aiEnabled
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300'
]}>
<i class:list={[
'fas',
aiEnabled ? 'fa-wave-square' : 'fa-triangle-exclamation'
]}></i>
{aiEnabled ? '已待命' : '休息中'}
</span>
</div>
<SharePanel <SharePanel
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`} shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })} summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
@@ -136,13 +152,11 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
description={sharePanelCopy.description} description={sharePanelCopy.description}
stats={[ stats={[
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) }, { label: sharePanelCopy.examples, value: String(sampleQuestions.length) },
{ label: sharePanelCopy.ai, value: aiEnabled ? t('common.featureOn') : t('common.featureOff') }, { label: sharePanelCopy.status, value: sharePanelCopy.statusValue },
]} ]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled} wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/> />
</div>
<div class="px-5 pt-6">
<DiscoveryBrief <DiscoveryBrief
badge={isEnglish ? 'ask brief' : '问答摘要'} badge={isEnglish ? 'ask brief' : '问答摘要'}
kicker="geo / ai" kicker="geo / ai"
@@ -151,20 +165,19 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
highlights={askHighlights} highlights={askHighlights}
faqs={askFaqs} faqs={askFaqs}
/> />
</div>
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]"> <div class="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_18rem]">
<div class="min-w-0"> <div class="min-w-0 space-y-5">
{aiEnabled ? ( {aiEnabled ? (
<> <>
<form id="ai-form" class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4"> <form id="ai-form" class="terminal-panel-muted space-y-4">
<CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" /> <CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" />
<textarea <textarea
id="ai-question" id="ai-question"
class="min-h-[140px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]" class="min-h-[160px] w-full resize-y rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] px-4 py-3 font-mono text-sm text-[var(--text)] outline-none transition focus:border-[var(--primary)]"
placeholder={t('ask.textareaPlaceholder')} placeholder={t('ask.textareaPlaceholder')}
></textarea> ></textarea>
<div class="mt-4 flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary"> <button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-terminal text-xs"></i> <i class="fas fa-terminal text-xs"></i>
<span>{t('ask.submit')}</span> <span>{t('ask.submit')}</span>
@@ -173,7 +186,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </div>
</form> </form>
<div id="ai-result" class="mt-6 hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/65 p-5"> <div id="ai-result" class="terminal-panel-muted hidden">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.assistantLabel')}</div> <div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.assistantLabel')}</div>
<div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div> <div id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
@@ -183,7 +196,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </div>
</> </>
) : ( ) : (
<div class="rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/55 px-5 py-8"> <div class="terminal-panel-muted border-dashed px-5 py-8">
<div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.disabledStateLabel')}</div> <div class="text-xs uppercase tracking-[0.24em] text-[var(--text-tertiary)]">{t('ask.disabledStateLabel')}</div>
<h2 class="mt-3 text-xl font-semibold text-[var(--title-color)]">{t('ask.disabledTitle')}</h2> <h2 class="mt-3 text-xl font-semibold text-[var(--title-color)]">{t('ask.disabledTitle')}</h2>
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]"> <p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
@@ -194,7 +207,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </div>
<aside class="space-y-4"> <aside class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4"> <div class="terminal-panel-muted">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div> <div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
{sampleQuestions.map((question) => ( {sampleQuestions.map((question) => (
@@ -209,7 +222,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</div> </div>
</div> </div>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4"> <div class="terminal-panel-muted">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.guide')}</div> <div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.guide')}</div>
<ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]"> <ol class="mt-4 space-y-2 text-sm leading-7 text-[var(--text-secondary)]">
<li>{t('ask.guide1')}</li> <li>{t('ask.guide1')}</li>
@@ -220,9 +233,255 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
</aside> </aside>
</div> </div>
</div> </div>
</div>
</TerminalWindow>
</section> </section>
</BaseLayout> </BaseLayout>
<style is:global>
.ask-connecting-shell {
display: inline-flex;
align-items: center;
gap: 0.95rem;
min-width: min(100%, 20rem);
border-radius: 1.1rem;
border: 1px solid color-mix(in oklab, var(--primary) 12%, var(--border-color));
background:
radial-gradient(circle at top right, rgba(var(--primary-rgb), 0.11), transparent 28%),
radial-gradient(circle at bottom left, rgba(var(--primary-rgb), 0.05), transparent 34%),
linear-gradient(
180deg,
color-mix(in oklab, var(--terminal-bg) 98%, transparent),
color-mix(in oklab, var(--header-bg) 92%, transparent)
);
padding: 0.95rem 1.05rem;
box-shadow:
0 14px 28px rgba(15, 23, 42, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 0 0 1px rgba(var(--primary-rgb), 0.03);
}
.ask-gemini-avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.2rem;
height: 2.2rem;
flex: 0 0 2.2rem;
border-radius: 999px;
background:
radial-gradient(circle at 30% 30%, rgba(160, 211, 255, 0.24), transparent 55%),
radial-gradient(circle at 70% 70%, rgba(var(--primary-rgb), 0.2), transparent 48%),
color-mix(in oklab, var(--terminal-bg) 88%, rgba(var(--primary-rgb), 0.16));
animation: ask-gemini-avatar-breathe 3.2s ease-in-out infinite;
}
.ask-gemini-avatar::before,
.ask-gemini-avatar::after {
content: '';
position: absolute;
inset: -0.3rem;
border-radius: 999px;
pointer-events: none;
}
.ask-gemini-avatar::before {
background: radial-gradient(circle, rgba(var(--primary-rgb), 0.24), transparent 72%);
filter: blur(9px);
animation: ask-gemini-halo 3.2s ease-in-out infinite;
}
.ask-gemini-avatar::after {
inset: 0.2rem;
border: 1px solid rgba(255, 255, 255, 0.08);
opacity: 0.8;
}
.ask-gemini-orbit {
position: absolute;
inset: -0.52rem;
border-radius: 999px;
animation: ask-gemini-orbit-spin 2.15s linear infinite;
pointer-events: none;
}
.ask-gemini-orbit-svg {
width: 100%;
height: 100%;
overflow: visible;
transform: rotate(-90deg);
filter:
drop-shadow(0 0 8px rgba(var(--primary-rgb), 0.22))
drop-shadow(0 0 16px rgba(79, 160, 255, 0.16));
}
.ask-gemini-orbit-ring {
fill: none;
stroke: url(#ask-gemini-orbit-gradient);
stroke-width: 2.3;
stroke-linecap: butt;
stroke-dasharray: 34 110;
stroke-dashoffset: 0;
opacity: 1;
animation:
ask-gemini-orbit-length 2.1s cubic-bezier(0.6, 0.08, 0.28, 0.96) infinite,
ask-gemini-orbit-glow 3.6s ease-in-out infinite;
}
.ask-gemini-svg {
position: relative;
z-index: 1;
width: 2rem;
height: 2rem;
overflow: visible;
}
.ask-gemini-star-outline {
opacity: 0.25;
}
.ask-gemini-sweep {
transform-box: fill-box;
transform-origin: center;
animation: ask-gemini-sweep 2.9s cubic-bezier(0.55, 0.08, 0.28, 0.98) infinite;
}
.ask-gemini-sweep-secondary {
animation-delay: 1.15s;
opacity: 0.68;
}
.ask-connecting-copy {
min-width: 0;
color: var(--text);
font-size: 0.94rem;
line-height: 1.5;
letter-spacing: 0.01em;
}
.ask-connecting-copy span {
display: block;
color: color-mix(in oklab, var(--title-color) 72%, var(--text-secondary));
text-wrap: balance;
}
@keyframes ask-gemini-avatar-breathe {
0%,
100% {
transform: translateY(0) scale(0.98);
}
50% {
transform: translateY(-0.02rem) scale(1.04);
}
}
@keyframes ask-gemini-halo {
0%,
100% {
opacity: 0.45;
transform: scale(0.9);
}
50% {
opacity: 0.9;
transform: scale(1.08);
}
}
@keyframes ask-gemini-orbit-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ask-gemini-orbit-glow {
0%,
100% {
opacity: 0.7;
filter: saturate(0.96);
}
50% {
opacity: 1;
filter: saturate(1.12);
}
}
@keyframes ask-gemini-orbit-length {
0% {
stroke-dasharray: 30 102;
stroke-dashoffset: 4;
opacity: 0.78;
}
38% {
stroke-dasharray: 64 68;
stroke-dashoffset: -14;
opacity: 0.92;
}
68% {
stroke-dasharray: 96 36;
stroke-dashoffset: -30;
opacity: 0.98;
}
88% {
stroke-dasharray: 118 14;
stroke-dashoffset: -48;
opacity: 1;
}
96% {
stroke-dasharray: 124 8;
stroke-dashoffset: -56;
opacity: 1;
}
100% {
stroke-dasharray: 30 102;
stroke-dashoffset: -64;
opacity: 0.78;
}
}
@keyframes ask-gemini-sweep {
0% {
opacity: 0;
transform: translate3d(-0.6rem, 0, 0) rotate(-34deg) scaleX(0.86);
}
18% {
opacity: 0.95;
}
56% {
opacity: 0.74;
}
100% {
opacity: 0;
transform: translate3d(0.7rem, 0, 0) rotate(-34deg) scaleX(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.ask-gemini-avatar,
.ask-gemini-avatar::before,
.ask-gemini-orbit,
.ask-gemini-sweep,
.ask-gemini-sweep-secondary {
animation: none;
}
}
</style>
{aiEnabled && ( {aiEnabled && (
<script is:inline define:vars={{ apiBase: publicApiBaseUrl }}> <script is:inline define:vars={{ apiBase: publicApiBaseUrl }}>
const t = window.__termiTranslate; const t = window.__termiTranslate;
@@ -317,6 +576,71 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
return html.join(''); return html.join('');
} }
function renderConnectingState(message) {
return `
<div class="ask-connecting-shell" role="status" aria-live="polite">
<span class="ask-gemini-avatar" aria-hidden="true">
<span class="ask-gemini-orbit">
<svg class="ask-gemini-orbit-svg" viewBox="0 0 48 48" focusable="false">
<defs>
<linearGradient id="ask-gemini-orbit-gradient" x1="5" y1="43" x2="43" y2="5" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#2563eb"></stop>
<stop offset="48%" stop-color="#22d3ee"></stop>
<stop offset="78%" stop-color="#fde68a"></stop>
<stop offset="100%" stop-color="#ffffff"></stop>
</linearGradient>
</defs>
<circle class="ask-gemini-orbit-ring" cx="24" cy="24" r="21"></circle>
</svg>
</span>
<svg class="ask-gemini-svg" viewBox="0 0 32 32" width="32" height="32" focusable="false">
<defs>
<linearGradient id="ask-gemini-base" x1="5" y1="26" x2="27" y2="6" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#346bf1"></stop>
<stop offset="22%" stop-color="#3279f8"></stop>
<stop offset="45%" stop-color="#3186ff"></stop>
<stop offset="72%" stop-color="#4093ff"></stop>
<stop offset="100%" stop-color="#4fa0ff"></stop>
</linearGradient>
<linearGradient id="ask-gemini-aurora" x1="7" y1="23" x2="25" y2="9" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"></stop>
<stop offset="42%" stop-color="#ffffff" stop-opacity="0.96"></stop>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"></stop>
</linearGradient>
<radialGradient id="ask-gemini-core" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.9"></stop>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"></stop>
</radialGradient>
<clipPath id="ask-gemini-star-clip">
<path d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"></path>
</clipPath>
</defs>
<circle cx="16" cy="16" r="11" fill="url(#ask-gemini-core)" opacity="0.22"></circle>
<path
d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"
fill="url(#ask-gemini-base)"
></path>
<g clip-path="url(#ask-gemini-star-clip)">
<rect class="ask-gemini-sweep" x="2" y="13.7" width="28" height="4.2" rx="2.1" fill="url(#ask-gemini-aurora)"></rect>
<rect class="ask-gemini-sweep ask-gemini-sweep-secondary" x="1" y="14.7" width="30" height="2.7" rx="1.35" fill="url(#ask-gemini-aurora)"></rect>
</g>
<path
class="ask-gemini-star-outline"
d="M16 1.7C15.25 7.05 13.74 11.02 11.93 12.83C10.12 14.64 6.15 16.15 0.8 16.9C6.15 17.65 10.12 19.16 11.93 20.97C13.74 22.78 15.25 26.75 16 32.1C16.75 26.75 18.26 22.78 20.07 20.97C21.88 19.16 25.85 17.65 31.2 16.9C25.85 16.15 21.88 14.64 20.07 12.83C18.26 11.02 16.75 7.05 16 1.7Z"
fill="none"
stroke="#ffffff"
stroke-opacity="0.55"
stroke-width="0.5"
></path>
</svg>
</span>
<span class="ask-connecting-copy">
<span>${escapeHtml(message || t('ask.connectingShort'))}</span>
</span>
</div>
`;
}
function setInteractiveState(isLoading) { function setInteractiveState(isLoading) {
if (submit) { if (submit) {
submit.toggleAttribute('disabled', isLoading); submit.toggleAttribute('disabled', isLoading);
@@ -512,7 +836,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
setInteractiveState(true); setInteractiveState(true);
result.classList.remove('hidden'); result.classList.remove('hidden');
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`; answer.innerHTML = renderConnectingState(t('ask.connectingShort'));
sources.innerHTML = ''; sources.innerHTML = '';
meta.textContent = ''; meta.textContent = '';
updatePrompt(t('ask.promptSubmitting')); updatePrompt(t('ask.promptSubmitting'));

View File

@@ -227,16 +227,6 @@ const discoverPrompt = hasActiveFilters
const postsPrompt = hasActiveFilters const postsPrompt = hasActiveFilters
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') }) ? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
: t('home.promptPostsDefault', { count: previewCount }); : t('home.promptPostsDefault', { count: previewCount });
const navLinks = [
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, ''); const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, '');
const homeJsonLd = [ const homeJsonLd = [
{ {
@@ -262,14 +252,18 @@ 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.', 'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
} }
: { : {
badge: '页', badge: '入口页',
title: '分享首页', title: '把首页甩出去',
description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。', description: '不知道发什么时,先发这个入口。轻松、不剧透,还挺省心。',
}; };
const homeShareSummary = isEnglish
? 'A light entry point for curious visitors. Click around and let the rest reveal itself.'
: '这是一个适合顺手转发的小入口,先逛逛,细节留到点开再说。';
const homeSidebarCopy = isEnglish const homeSidebarCopy = isEnglish
? { ? {
quickLinks: 'Quick links', quickLinks: 'Quick links',
quickLinksDesc: 'Jump to the main sections of the site.', quickLinksDesc: 'Keep the main channels pinned on the right so you can switch context without losing reading flow.',
quickLinksMore: 'More channels',
popularTitle: 'Hot now', popularTitle: 'Hot now',
popularDesc: 'Track the most-read content in the selected window.', popularDesc: 'Track the most-read content in the selected window.',
friendsTitle: 'Friend links', friendsTitle: 'Friend links',
@@ -280,15 +274,28 @@ const homeSidebarCopy = isEnglish
} }
: { : {
quickLinks: '快速入口', quickLinks: '快速入口',
quickLinksDesc: '常用入口收进侧栏,首页阅读流更清爽。', quickLinksDesc: '常用入口都放这儿,手别忙,点就行。',
quickLinksMore: '更多频道',
popularTitle: '最近热门', popularTitle: '最近热门',
popularDesc: '按当前时间窗口查看最受关注的内容。', popularDesc: '看看最近是谁在悄悄抢镜。',
friendsTitle: '友情链接', friendsTitle: '友情链接',
friendsDesc: '先看几个常访问的站点入口。', friendsDesc: '隔壁摊位也许也有好东西。',
statsTitle: '站点概览', statsTitle: '站点概览',
statsDesc: '快速看一下当前站点规模与内容状态。', statsDesc: '轻量围观一下站内气氛,不必知道太多。',
aiBriefTitle: '站点摘要', aiBriefTitle: '站点便签',
}; };
const primaryQuickLinks = [
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
const secondaryQuickLinks = [
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
];
const homeBriefHighlights = buildDiscoveryHighlights([ const homeBriefHighlights = buildDiscoveryHighlights([
siteSettings.siteDescription, siteSettings.siteDescription,
siteSettings.heroSubtitle, siteSettings.heroSubtitle,
@@ -609,18 +616,18 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
</div> </div>
<aside class="home-sidebar-stack xl:sticky xl:top-24 xl:self-start"> <aside class="home-sidebar-stack">
<section class="terminal-panel home-sidebar-card space-y-4"> <section class="terminal-panel home-sidebar-card home-sidebar-card--quickmenu space-y-4 xl:sticky xl:top-24">
<div class="space-y-1"> <div class="space-y-1">
<span class="terminal-kicker w-fit"> <span class="terminal-kicker w-fit">
<i class="fas fa-compass"></i> <i class="fas fa-compass"></i>
side nav quick menu
</span> </span>
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.quickLinks}</h3> <h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.quickLinks}</h3>
<p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.quickLinksDesc}</p> <p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.quickLinksDesc}</p>
</div> </div>
<div class="home-sidebar-grid"> <div class="home-sidebar-grid">
{navLinks.map(link => ( {primaryQuickLinks.map(link => (
<a <a
href={link.href} href={link.href}
class="home-sidebar-link group flex min-w-0 items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 px-3 py-2.5 text-sm text-[var(--title-color)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]" class="home-sidebar-link group flex min-w-0 items-center gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 px-3 py-2.5 text-sm text-[var(--title-color)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
@@ -633,11 +640,29 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</a> </a>
))} ))}
</div> </div>
<div class="space-y-1 border-t border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] pt-4">
<h4 class="text-sm font-semibold text-[var(--title-color)]">{homeSidebarCopy.quickLinksMore}</h4>
</div>
<div class="grid gap-2 sm:grid-cols-2 xl:grid-cols-2">
{secondaryQuickLinks.map(link => (
<a
href={link.href}
class="home-sidebar-mini-link group flex min-w-0 items-center gap-2.5 rounded-xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 px-3 py-2 text-xs text-[var(--text-secondary)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
>
<span class="home-sidebar-mini-link__icon">
<i class={`fas ${link.icon} text-[10px]`}></i>
</span>
<span class="min-w-0 flex-1 truncate font-medium">{link.text}</span>
</a>
))}
</div>
</section> </section>
<SharePanel <SharePanel
shareTitle={siteSettings.siteTitle} shareTitle={siteSettings.siteTitle}
summary={siteSettings.heroSubtitle || siteSettings.siteDescription} summary={homeShareSummary}
canonicalUrl={siteBaseUrl} canonicalUrl={siteBaseUrl}
badge={homeShareCopy.badge} badge={homeShareCopy.badge}
title={homeShareCopy.title} title={homeShareCopy.title}
@@ -737,22 +762,22 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div> </div>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-2"> <div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-2">
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4"> <div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.views')}</p> <p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.views')}</p>
<p id="home-reading-views-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p> <p id="home-reading-views-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p> <p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
</div> </div>
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4"> <div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.completes')}</p> <p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.completes')}</p>
<p id="home-reading-completes-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p> <p id="home-reading-completes-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p> <p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
</div> </div>
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4"> <div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgProgress')}</p> <p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgProgress')}</p>
<p id="home-reading-progress-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatProgressPercent(activeContentRange.overview.avgReadProgress)}</p> <p id="home-reading-progress-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatProgressPercent(activeContentRange.overview.avgReadProgress)}</p>
<p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p> <p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
</div> </div>
<div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4"> <div class="home-sidebar-stat home-sidebar-stat--metric rounded-2xl border border-[color:color-mix(in_oklab,var(--primary)_10%,var(--border-color))] p-4">
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgDuration')}</p> <p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgDuration')}</p>
<p id="home-reading-duration-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatDurationMs(activeContentRange.overview.avgReadDurationMs)}</p> <p id="home-reading-duration-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatDurationMs(activeContentRange.overview.avgReadDurationMs)}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('common.posts')}: {allPosts.length}</p> <p class="mt-2 text-xs text-[var(--text-secondary)]">{t('common.posts')}: {allPosts.length}</p>
@@ -848,7 +873,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
<div class="my-8 border-t border-[var(--border-color)]"></div> <div class="my-8 border-t border-[var(--border-color)]"></div>
<div class="px-4 pb-2"> <div id="site-brief" class="px-4 pb-2">
<div class="ml-4"> <div class="ml-4">
<DiscoveryBrief <DiscoveryBrief
badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle} badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle}

File diff suppressed because it is too large Load Diff