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

View File

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

View File

@@ -35,6 +35,14 @@ const navItems = [
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(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) => ({
locale: item,
href: buildLocaleUrl(item),
@@ -149,7 +157,7 @@ const currentNavLabel =
{aiEnabled && (
<a
href="/ask"
class="inline-flex shrink-0 items-center gap-2 rounded-xl border border-[var(--primary)]/18 bg-[var(--primary)]/8 px-2.5 py-1.5 text-sm font-medium text-[var(--primary)] transition hover:border-[var(--primary)]/32 hover:text-[var(--title-color)]"
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>
<span class="hidden xl:inline">{t('nav.ask')}</span>
@@ -357,6 +365,40 @@ const currentNavLabel =
</div>
</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 }}>
const t = window.__termiTranslate;
@@ -365,12 +407,16 @@ const currentNavLabel =
const mobileMenu = document.getElementById('mobile-menu');
const mobileSearchInput = document.getElementById('mobile-search-input');
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');
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) => {
link.addEventListener('click', () => {
@@ -715,7 +761,7 @@ const currentNavLabel =
if (state === 'loading') {
searchResults.innerHTML = `
<div class="px-4 py-4 text-sm text-[var(--text-secondary)]">
${escapeHtml(t('header.searching', { query }))}
${escapeHtml(t('header.searching', { query }))}
</div>
`;
searchResults.classList.remove('hidden');

View File

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

View File

@@ -16,7 +16,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
<div
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">
<span class="terminal-kicker w-fit">
@@ -130,8 +130,8 @@ const hasBeforeNav = Astro.slots.has('before-nav');
height: 0.52rem;
margin-top: 0.42rem;
border-radius: 999px;
background: color-mix(in oklab, var(--border-color) 82%, white 18%);
box-shadow: 0 0 0 6px color-mix(in oklab, var(--card-bg, white) 92%, transparent);
background: color-mix(in oklab, var(--border-color) 82%, var(--terminal-bg));
box-shadow: 0 0 0 6px color-mix(in oklab, var(--terminal-bg) 92%, transparent);
transition:
background-color 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 {
background: var(--primary);
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 {
@@ -189,6 +189,7 @@ const hasBeforeNav = Astro.slots.has('before-nav');
const tocPanel = document.getElementById('toc-panel');
const container = document.getElementById('toc-container');
const hasBeforeNav = container?.getAttribute('data-has-before-nav') === 'true';
const header = document.querySelector('header');
if (!tocNav || headings.length === 0) {
if (tocPanel) tocPanel.style.display = 'none';
@@ -196,6 +197,47 @@ const hasBeforeNav = Astro.slots.has('before-nav');
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 = '';
headings.forEach((heading, index) => {
if (!heading.id) {
@@ -212,32 +254,103 @@ const hasBeforeNav = Astro.slots.has('before-nav');
link.addEventListener('click', (e) => {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth', block: 'start' });
setActiveLink(heading.id);
scrollToHeading(heading);
window.history.replaceState(null, '', `#${heading.id}`);
});
tocNav.appendChild(link);
});
const observer = new IntersectionObserver(
(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' }
);
updateHeadingOffset();
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') {

View File

@@ -31,7 +31,7 @@ const {
title = isEnglish ? 'Quick share' : '一键分享',
description = isEnglish
? 'Keep canonical links flowing through social channels so both people and AI search engines can converge on the same source.'
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
: '复制链接、带走二维码,轻轻一发,不剧透太多。',
stats = [],
wechatShareQrEnabled = false,
variant = 'default',
@@ -70,30 +70,30 @@ const copy = isEnglish
toastInfoTitle: 'Share ready',
}
: {
summaryTitle: '页面简介',
canonical: '固定链接',
copySummary: '复制简介',
copySummarySuccess: '页面简介已复制',
summaryTitle: '小纸条',
canonical: '传送门',
copySummary: '复制小纸条',
copySummarySuccess: '小纸条已复制',
copySummaryFailed: '复制失败',
copyLink: '复制固定链接',
copyLinkSuccess: '固定链接已复制',
copyLinkFailed: '固定链接复制失败',
shareSummary: '直接分享',
shareSuccess: '已打开系统分享',
shareFallback: '分享内容已复制',
copyLink: '复制传送门',
copyLinkSuccess: '传送门已复制',
copyLinkFailed: '传送门复制失败',
shareSummary: '一键甩出',
shareSuccess: '系统分享已就位',
shareFallback: '分享话术已复制',
shareFailed: '分享失败',
shareToX: '分享到 X',
shareToTelegram: '分享到 Telegram',
shareToWeChat: '微信扫一扫',
qrModalTitle: '微信扫一扫',
qrModalDescription: '用微信扫一扫,就能在手机上继续浏览当前页面。',
qrModalHint: '如果要发给别人,直接复制下方链接会更方便。',
qrModalDescription: '扫一下,手机上继续逛。',
qrModalHint: '真要转发,丢链接通常更省事。',
downloadQr: '下载二维码',
downloadQrStarted: '二维码开始下载',
qrOpened: '微信二维码已打开',
toastSuccessTitle: '操作完成',
toastErrorTitle: '操作失败',
toastInfoTitle: '已准备好',
toastSuccessTitle: '搞定',
toastErrorTitle: '这次没接住',
toastInfoTitle: '可以发了',
};
const safeSummary = summary.trim() || shareTitle;
@@ -140,7 +140,9 @@ if (wechatShareQrEnabled) {
<section
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',
]}
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="space-y-3">
<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>
{visibleBadge}
</span>
@@ -167,7 +169,7 @@ if (wechatShareQrEnabled) {
{stats.length > 0 ? (
<div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && 'lg:min-w-[16rem]']}>
{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="mt-2 text-lg font-semibold text-[var(--title-color)]">{item.value}</div>
</div>
@@ -177,7 +179,7 @@ if (wechatShareQrEnabled) {
</div>
<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',
]}>
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
@@ -186,55 +188,55 @@ if (wechatShareQrEnabled) {
</p>
</div>
<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">
<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>
<button
type="button"
class="terminal-action-button terminal-action-button-primary"
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>
<p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
</div>
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
{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 channels' : '分享渠道'}
{isEnglish ? 'Share actions' : '分享操作'}
</p>
{!isCompact && <p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>}
</div>
<div class="flex flex-wrap gap-2">
<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"
@@ -258,7 +260,7 @@ if (wechatShareQrEnabled) {
{wechatShareQrEnabled && wechatShareQrSvg ? (
<button
type="button"
class="terminal-action-button"
class="terminal-action-button share-panel-compact-actions__wechat"
data-share-wechat-open
>
<i class="fab fa-weixin"></i>
@@ -266,97 +268,187 @@ if (wechatShareQrEnabled) {
</button>
) : null}
</div>
</div>
</div>
<div class="mt-4 rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/82 p-4">
<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="flex flex-wrap gap-2">
<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>
<button
type="button"
class="terminal-action-button terminal-action-button-primary"
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>
<p class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]" data-share-status aria-live="polite"></p>
</div>
<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="space-y-1">
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{isEnglish ? 'Share channels' : '分享渠道'}
</p>
<p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
</div>
<div class="flex flex-wrap gap-2">
<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"
data-share-wechat-open
>
<i class="fab fa-weixin"></i>
<span>{copy.shareToWeChat}</span>
</button>
) : null}
</div>
</div>
</div>
</>
)}
<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>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
{wechatShareQrEnabled && wechatShareQrSvg ? (
<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
aria-hidden="true"
>
<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="flex items-start justify-between gap-4">
<div class="space-y-2">
<span class="terminal-kicker">
<i class="fab fa-weixin"></i>
{copy.shareToWeChat}
</span>
<div>
<h3 class="text-2xl font-semibold text-[var(--title-color)]">{copy.qrModalTitle}</h3>
<p class="mt-2 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
{copy.qrModalDescription}
</p>
<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="space-y-2">
<span class="terminal-kicker">
<i class="fab fa-weixin"></i>
{copy.shareToWeChat}
</span>
<div>
<h3 class="text-2xl font-semibold text-[var(--title-color)]">{copy.qrModalTitle}</h3>
<p class="mt-2 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
{copy.qrModalDescription}
</p>
</div>
</div>
<button
type="button"
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
aria-label={t('common.close')}
>
<i class="fas fa-xmark text-base"></i>
</button>
</div>
<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)]"
data-share-wechat-close
aria-label={t('common.close')}
>
<i class="fas fa-xmark text-base"></i>
</button>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
</div>
<div class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical}
</div>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
<div class="share-wechat-modal-surface mx-auto w-full max-w-[260px] rounded-[28px] p-5">
<div class="overflow-hidden rounded-2xl bg-white" set:html={wechatShareQrSvg}></div>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle}
<div class="space-y-4">
<div class="share-wechat-modal-surface rounded-2xl p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.canonical}
</div>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{safeSummary}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="terminal-action-button terminal-action-button-primary"
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>
<a
href={wechatShareQrPngDataUrl}
download={`${panelId}-wechat-share-qr.png`}
class="terminal-action-button"
data-share-qr-download
>
<i class="fas fa-download"></i>
<span>{copy.downloadQr}</span>
</a>
<button
type="button"
class="terminal-action-button"
data-share-wechat-close
>
<i class="fas fa-xmark"></i>
<span>{t('common.close')}</span>
</button>
<div class="share-wechat-modal-surface rounded-2xl p-5">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{copy.summaryTitle}
</div>
<p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{shareTitle}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{safeSummary}</p>
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{copy.qrModalHint}</p>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="terminal-action-button terminal-action-button-primary"
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>
<a
href={wechatShareQrPngDataUrl}
download={`${panelId}-wechat-share-qr.png`}
class="terminal-action-button"
data-share-qr-download
>
<i class="fas fa-download"></i>
<span>{copy.downloadQr}</span>
</a>
<button
type="button"
class="terminal-action-button"
data-share-wechat-close
>
<i class="fas fa-xmark"></i>
<span>{t('common.close')}</span>
</button>
</div>
</div>
</div>
</div>
@@ -393,6 +485,118 @@ if (wechatShareQrEnabled) {
</div>
</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
is:inline
define:vars={{
@@ -424,6 +628,10 @@ if (wechatShareQrEnabled) {
const toastMessage = root.querySelector('[data-share-toast-message]');
let toastTimer = 0;
if (wechatModal instanceof HTMLElement && wechatModal.parentElement !== document.body) {
document.body.appendChild(wechatModal);
}
function setStatus(message) {
if (!status) return;
status.textContent = message || '';

View File

@@ -501,7 +501,7 @@ const i18nPayload = JSON.stringify({ locale, messages });
<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 />
</main>

View File

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

View File

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

View File

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

View File

@@ -437,7 +437,7 @@ const breadcrumbJsonLd = {
</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)]">
<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 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>
@@ -458,7 +458,7 @@ const breadcrumbJsonLd = {
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.digestDescription}</p>
</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">
{articlePreviewParagraphs.map((paragraph) => (
<p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p>
@@ -500,7 +500,7 @@ const breadcrumbJsonLd = {
</div>
<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="space-y-1">
<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">
{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="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
</div>
@@ -558,7 +558,7 @@ const breadcrumbJsonLd = {
</div>
<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)]">
{articleCopy.sourceTitle}
</h3>
@@ -601,13 +601,13 @@ const breadcrumbJsonLd = {
</div>
{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)]">
{articleCopy.highlightsTitle}
</h3>
<div class="mt-4 space-y-3">
{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)]">
{index + 1}
</span>
@@ -828,7 +828,7 @@ const breadcrumbJsonLd = {
>
<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"
title={articleCopy.copySummary}
aria-label={articleCopy.copySummary}
@@ -837,7 +837,7 @@ const breadcrumbJsonLd = {
</button>
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
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"
title={articleCopy.shareSummary}
aria-label={articleCopy.shareSummary}
@@ -847,7 +847,7 @@ const breadcrumbJsonLd = {
{wechatShareQrEnabled && wechatShareQrSvg && (
<button
type="button"
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
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"
title={articleCopy.shareToWeChat}
aria-label={articleCopy.shareToWeChat}
@@ -857,7 +857,7 @@ const breadcrumbJsonLd = {
)}
<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"
title={t('common.copyPermalink')}
aria-label={t('common.copyPermalink')}
@@ -903,19 +903,19 @@ const breadcrumbJsonLd = {
</div>
<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>
<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)]">
{articleCopy.canonical}
</div>
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--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)]">
{articleCopy.digestTitle}
</div>

View File

@@ -4,6 +4,7 @@ import DiscoveryBrief from '../../components/seo/DiscoveryBrief.astro';
import PageViewTracker from '../../components/seo/PageViewTracker.astro';
import SharePanel from '../../components/seo/SharePanel.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 { getI18n } from '../../lib/i18n';
import { buildDiscoveryHighlights, buildFaqJsonLd, buildPageFaqs, compactJsonLd } from '../../lib/seo';
@@ -70,14 +71,16 @@ const sharePanelCopy = isEnglish
description:
'Share the sites AI query interface as a canonical entry for question-driven discovery, backed by stable internal sources and citations.',
examples: 'Prompts',
ai: 'AI',
status: 'Status',
statusValue: aiEnabled ? 'Ready' : 'Idle',
}
: {
badge: '问答入口',
title: '分享问答页',
description: '把这个问答页分享给需要快速检索站内内容的人。',
title: '把问答入口甩出去',
description: '有人想少走弯路时,把这页递过去就行。',
examples: '示例问题',
ai: 'AI',
status: '状态',
statusValue: aiEnabled ? '随时开问' : '暂时休息',
};
const askHighlights = buildDiscoveryHighlights([
t('ask.subtitle'),
@@ -107,122 +110,378 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
jsonLd={compactJsonLd([...askJsonLd, askFaqJsonLd])}
>
<PageViewTracker pageType="ask" entityId="ask" />
<section class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<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">
<div class="flex items-center justify-between gap-4 border-b border-[var(--border-color)] px-5 py-4">
<div>
<div class="text-xs uppercase tracking-[0.26em] text-[var(--text-tertiary)]">{t('ask.terminalLabel')}</div>
<h1 class="mt-2 text-2xl font-bold text-[var(--title-color)]">{t('ask.title')}</h1>
<p class="mt-2 text-sm text-[var(--text-secondary)]">{t('ask.subtitle')}</p>
</div>
<div class:list={[
'rounded-full border px-3 py-1 text-xs font-mono',
aiEnabled
? 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300'
: 'border-amber-500/35 bg-amber-500/10 text-amber-600 dark:text-amber-300'
]}>
{aiEnabled ? t('common.featureOn') : t('common.featureOff')}
</div>
</div>
<div class="px-5 pt-6">
<SharePanel
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
canonicalUrl={askCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / ai"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) },
{ label: sharePanelCopy.ai, value: aiEnabled ? t('common.featureOn') : t('common.featureOff') },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
</div>
<div class="px-5 pt-6">
<DiscoveryBrief
badge={isEnglish ? 'ask brief' : '问答摘要'}
kicker="geo / ai"
title={isEnglish ? 'AI-readable ask-page brief' : '给 AI 看的问答页摘要'}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
highlights={askHighlights}
faqs={askFaqs}
/>
</div>
<div class="grid gap-8 px-5 py-6 lg:grid-cols-[minmax(0,1.5fr)_18rem]">
<div class="min-w-0">
{aiEnabled ? (
<>
<form id="ai-form" class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
<CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" />
<textarea
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)]"
placeholder={t('ask.textareaPlaceholder')}
></textarea>
<div class="mt-4 flex flex-wrap items-center gap-3">
<button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-terminal text-xs"></i>
<span>{t('ask.submit')}</span>
</button>
<span id="ai-status" class="text-sm text-[var(--text-secondary)]">{t('ask.idleStatus')}</span>
</div>
</form>
<div id="ai-result" class="mt-6 hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/65 p-5">
<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 id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
</div>
<div id="ai-answer" class="terminal-document mt-4"></div>
<div id="ai-sources" class="mt-5 grid gap-3"></div>
<section class="mx-auto max-w-[1660px] px-4 py-8 sm:px-6 lg:px-8">
<TerminalWindow title="~/ask" class="w-full">
<div class="px-4 pb-2">
<div class="terminal-panel ml-4 mt-4 space-y-6">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="space-y-3">
<span class="terminal-kicker">
<i class="fas fa-sparkles"></i>
{t('ask.terminalLabel')}
</span>
<div class="space-y-2">
<h1 class="text-2xl font-bold text-[var(--title-color)] sm:text-3xl">{t('ask.title')}</h1>
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)] sm:text-base">
{t('ask.subtitle')}
</p>
</div>
</>
) : (
<div class="rounded-2xl border border-dashed border-[var(--border-color)] bg-[var(--bg)]/55 px-5 py-8">
<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>
<p class="mt-3 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
{t('ask.disabledDescription')}
</p>
</div>
)}
</div>
<aside class="space-y-4">
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
<div class="mt-4 space-y-2">
{sampleQuestions.map((question) => (
<button
type="button"
class="sample-question terminal-option-card"
data-question={question}
>
{question}
</button>
))}
</div>
<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>
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/60 p-4">
<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)]">
<li>{t('ask.guide1')}</li>
<li>{t('ask.guide2')}</li>
<li>{t('ask.guide3')}</li>
</ol>
<SharePanel
shareTitle={`${t('ask.pageTitle')} | ${siteSettings.siteShortName || siteSettings.siteName}`}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
canonicalUrl={askCanonicalUrl}
badge={sharePanelCopy.badge}
kicker="geo / ai"
title={sharePanelCopy.title}
description={sharePanelCopy.description}
stats={[
{ label: sharePanelCopy.examples, value: String(sampleQuestions.length) },
{ label: sharePanelCopy.status, value: sharePanelCopy.statusValue },
]}
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
/>
<DiscoveryBrief
badge={isEnglish ? 'ask brief' : '问答摘要'}
kicker="geo / ai"
title={isEnglish ? 'AI-readable ask-page brief' : '给 AI 看的问答页摘要'}
summary={t('ask.pageDescription', { siteName: siteSettings.siteName })}
highlights={askHighlights}
faqs={askFaqs}
/>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_18rem]">
<div class="min-w-0 space-y-5">
{aiEnabled ? (
<>
<form id="ai-form" class="terminal-panel-muted space-y-4">
<CommandPrompt promptId="ask-session-prompt" command={t('ask.promptIdle')} path="~/ask" />
<textarea
id="ai-question"
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')}
></textarea>
<div class="flex flex-wrap items-center gap-3">
<button type="submit" id="ai-submit" class="terminal-action-button terminal-action-button-primary">
<i class="fas fa-terminal text-xs"></i>
<span>{t('ask.submit')}</span>
</button>
<span id="ai-status" class="text-sm text-[var(--text-secondary)]">{t('ask.idleStatus')}</span>
</div>
</form>
<div id="ai-result" class="terminal-panel-muted hidden">
<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 id="ai-meta" class="text-xs text-[var(--text-tertiary)]"></div>
</div>
<div id="ai-answer" class="terminal-document mt-4"></div>
<div id="ai-sources" class="mt-5 grid gap-3"></div>
</div>
</>
) : (
<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>
<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)]">
{t('ask.disabledDescription')}
</p>
</div>
)}
</div>
</aside>
<aside class="space-y-4">
<div class="terminal-panel-muted">
<div class="text-xs uppercase tracking-[0.22em] text-[var(--text-tertiary)]">{t('ask.examples')}</div>
<div class="mt-4 space-y-2">
{sampleQuestions.map((question) => (
<button
type="button"
class="sample-question terminal-option-card"
data-question={question}
>
{question}
</button>
))}
</div>
</div>
<div class="terminal-panel-muted">
<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)]">
<li>{t('ask.guide1')}</li>
<li>{t('ask.guide2')}</li>
<li>{t('ask.guide3')}</li>
</ol>
</div>
</aside>
</div>
</div>
</div>
</div>
</TerminalWindow>
</section>
</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 && (
<script is:inline define:vars={{ apiBase: publicApiBaseUrl }}>
const t = window.__termiTranslate;
@@ -317,6 +576,71 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
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) {
if (submit) {
submit.toggleAttribute('disabled', isLoading);
@@ -512,7 +836,7 @@ const askFaqJsonLd = buildFaqJsonLd(askFaqs);
setInteractiveState(true);
result.classList.remove('hidden');
answer.innerHTML = `<p>${escapeHtml(t('ask.connecting'))}</p>`;
answer.innerHTML = renderConnectingState(t('ask.connectingShort'));
sources.innerHTML = '';
meta.textContent = '';
updatePrompt(t('ask.promptSubmitting'));

View File

@@ -227,16 +227,6 @@ const discoverPrompt = hasActiveFilters
const postsPrompt = hasActiveFilters
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
: 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 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.',
}
: {
badge: '页',
title: '分享首页',
description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。',
badge: '入口页',
title: '把首页甩出去',
description: '不知道发什么时,先发这个入口。轻松、不剧透,还挺省心。',
};
const homeShareSummary = isEnglish
? 'A light entry point for curious visitors. Click around and let the rest reveal itself.'
: '这是一个适合顺手转发的小入口,先逛逛,细节留到点开再说。';
const homeSidebarCopy = isEnglish
? {
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',
popularDesc: 'Track the most-read content in the selected window.',
friendsTitle: 'Friend links',
@@ -280,15 +274,28 @@ const homeSidebarCopy = isEnglish
}
: {
quickLinks: '快速入口',
quickLinksDesc: '常用入口收进侧栏,首页阅读流更清爽。',
quickLinksDesc: '常用入口都放这儿,手别忙,点就行。',
quickLinksMore: '更多频道',
popularTitle: '最近热门',
popularDesc: '按当前时间窗口查看最受关注的内容。',
popularDesc: '看看最近是谁在悄悄抢镜。',
friendsTitle: '友情链接',
friendsDesc: '先看几个常访问的站点入口。',
friendsDesc: '隔壁摊位也许也有好东西。',
statsTitle: '站点概览',
statsDesc: '快速看一下当前站点规模与内容状态。',
aiBriefTitle: '站点摘要',
statsDesc: '轻量围观一下站内气氛,不必知道太多。',
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([
siteSettings.siteDescription,
siteSettings.heroSubtitle,
@@ -609,18 +616,18 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
</div>
<aside class="home-sidebar-stack xl:sticky xl:top-24 xl:self-start">
<section class="terminal-panel home-sidebar-card space-y-4">
<aside class="home-sidebar-stack">
<section class="terminal-panel home-sidebar-card home-sidebar-card--quickmenu space-y-4 xl:sticky xl:top-24">
<div class="space-y-1">
<span class="terminal-kicker w-fit">
<i class="fas fa-compass"></i>
side nav
quick menu
</span>
<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>
</div>
<div class="home-sidebar-grid">
{navLinks.map(link => (
{primaryQuickLinks.map(link => (
<a
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)]"
@@ -633,11 +640,29 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</a>
))}
</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>
<SharePanel
shareTitle={siteSettings.siteTitle}
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
summary={homeShareSummary}
canonicalUrl={siteBaseUrl}
badge={homeShareCopy.badge}
title={homeShareCopy.title}
@@ -737,22 +762,22 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
</div>
<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 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>
</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 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>
</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 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>
</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 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>
@@ -848,7 +873,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
<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">
<DiscoveryBrief
badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle}

File diff suppressed because it is too large Load Diff