Compare commits
2 Commits
36d505ece6
...
0f2342a713
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f2342a713 | |||
| 83f3c8d249 |
@@ -11,7 +11,7 @@ const badgeVariants = cva(
|
|||||||
default: 'border-primary/20 bg-primary/10 text-primary',
|
default: 'border-primary/20 bg-primary/10 text-primary',
|
||||||
secondary: 'border-border bg-secondary text-secondary-foreground',
|
secondary: 'border-border bg-secondary text-secondary-foreground',
|
||||||
outline: 'border-border/80 bg-background/60 text-muted-foreground',
|
outline: 'border-border/80 bg-background/60 text-muted-foreground',
|
||||||
success: 'border-emerald-500/20 bg-emerald-500/12 text-emerald-600',
|
success: 'border-emerald-300 bg-emerald-100 text-emerald-900',
|
||||||
warning: 'border-amber-500/20 bg-amber-500/12 text-amber-700',
|
warning: 'border-amber-500/20 bg-amber-500/12 text-amber-700',
|
||||||
danger: 'border-rose-500/20 bg-rose-500/12 text-rose-600',
|
danger: 'border-rose-500/20 bg-rose-500/12 text-rose-600',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
--card-foreground: oklch(0.18 0.02 255);
|
--card-foreground: oklch(0.18 0.02 255);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.18 0.02 255);
|
--popover-foreground: oklch(0.18 0.02 255);
|
||||||
--primary: oklch(0.57 0.17 255);
|
--primary: oklch(0.5 0.16 255);
|
||||||
--primary-foreground: oklch(0.98 0.01 255);
|
--primary-foreground: oklch(1 0 0);
|
||||||
--secondary: oklch(0.94 0.02 220);
|
--secondary: oklch(0.94 0.02 220);
|
||||||
--secondary-foreground: oklch(0.28 0.03 250);
|
--secondary-foreground: oklch(0.28 0.03 250);
|
||||||
--muted: oklch(0.95 0.01 250);
|
--muted: oklch(0.95 0.01 250);
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
--border: oklch(0.9 0.01 250);
|
--border: oklch(0.9 0.01 250);
|
||||||
--input: oklch(0.91 0.01 250);
|
--input: oklch(0.91 0.01 250);
|
||||||
--ring: oklch(0.57 0.17 255);
|
--ring: oklch(0.57 0.17 255);
|
||||||
--success: oklch(0.72 0.16 160);
|
--success: oklch(0.63 0.14 160);
|
||||||
--warning: oklch(0.81 0.16 78);
|
--warning: oklch(0.81 0.16 78);
|
||||||
--radius: 1.15rem;
|
--radius: 1.15rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export function LoginPage({
|
|||||||
const [password, setPassword] = useState('admin123')
|
const [password, setPassword] = useState('admin123')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-4 py-10">
|
<main
|
||||||
|
className="flex min-h-screen items-center justify-center px-4 py-10"
|
||||||
|
aria-labelledby="admin-login-title"
|
||||||
|
>
|
||||||
<div className="grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
<div className="grid w-full max-w-5xl gap-6 lg:grid-cols-[1.1fr_0.9fr]">
|
||||||
<Card className="overflow-hidden border-primary/12 bg-gradient-to-br from-card via-card to-primary/5">
|
<Card className="overflow-hidden border-primary/12 bg-gradient-to-br from-card via-card to-primary/5">
|
||||||
<CardHeader className="space-y-4">
|
<CardHeader className="space-y-4">
|
||||||
@@ -57,7 +60,7 @@ export function LoginPage({
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-3">
|
<CardTitle id="admin-login-title" className="flex items-center gap-3">
|
||||||
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl border border-primary/20 bg-primary/10 text-primary">
|
||||||
<LockKeyhole className="h-5 w-5" />
|
<LockKeyhole className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
@@ -119,6 +122,6 @@ export function LoginPage({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const currentNavLabel =
|
|||||||
|
|
||||||
<header data-ai-search-enabled={aiEnabled ? 'true' : 'false'} class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
|
<header data-ai-search-enabled={aiEnabled ? 'true' : 'false'} class="sticky top-0 z-50 border-b border-[var(--border-color)] backdrop-blur-xl" style="background-color: color-mix(in oklab, var(--bg) 88%, transparent);">
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5">
|
||||||
<div class="terminal-toolbar-shell">
|
<div class="terminal-toolbar-shell overflow-visible">
|
||||||
<div class="flex flex-col gap-2.5">
|
<div class="flex flex-col gap-2.5">
|
||||||
<div class="flex items-center gap-2 lg:flex-nowrap">
|
<div class="flex items-center gap-2 lg:flex-nowrap">
|
||||||
<a href="/" class="terminal-toolbar-module shrink-0 min-w-[9.5rem] px-2.5 py-1.5 hover:border-[var(--primary)] transition-all">
|
<a href="/" class="terminal-toolbar-module shrink-0 min-w-[9.5rem] px-2.5 py-1.5 hover:border-[var(--primary)] transition-all">
|
||||||
@@ -101,7 +101,7 @@ const currentNavLabel =
|
|||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
id="search-results"
|
id="search-results"
|
||||||
class="hidden absolute right-0 top-[calc(100%+12px)] z-20 w-[26rem] overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] shadow-[0_20px_40px_rgba(15,23,42,0.08)]"
|
class="hidden absolute left-0 top-[calc(100%+12px)] z-40 w-[min(34rem,calc(100vw-4rem))] overflow-hidden rounded-[24px] border border-[var(--border-color)]/80 bg-[color-mix(in_oklab,var(--terminal-bg)_94%,white)] shadow-[0_24px_60px_rgba(15,23,42,0.14)] backdrop-blur-xl"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,38 +8,176 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
|||||||
|
|
||||||
<aside
|
<aside
|
||||||
id="toc-container"
|
id="toc-container"
|
||||||
class="hidden w-full shrink-0 lg:block lg:w-72"
|
class="hidden w-full shrink-0 xl:block xl:w-[17.5rem] 2xl:w-[18.5rem]"
|
||||||
data-has-before-nav={hasBeforeNav ? 'true' : 'false'}
|
data-has-before-nav={hasBeforeNav ? 'true' : 'false'}
|
||||||
>
|
>
|
||||||
<div class="sticky top-24 space-y-4">
|
<div class="sticky top-28 space-y-4">
|
||||||
<slot name="before-nav" />
|
<slot name="before-nav" />
|
||||||
|
|
||||||
<div id="toc-panel" class="terminal-panel-muted space-y-4">
|
<div
|
||||||
<div class="space-y-3">
|
id="toc-panel"
|
||||||
<span class="terminal-kicker">
|
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"
|
||||||
|
>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<span class="terminal-kicker w-fit">
|
||||||
<i class="fas fa-terminal"></i>
|
<i class="fas fa-terminal"></i>
|
||||||
nav stack
|
nav stack
|
||||||
</span>
|
</span>
|
||||||
<div class="terminal-section-title">
|
|
||||||
<span class="terminal-section-icon">
|
|
||||||
<i class="fas fa-list-ul"></i>
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<h3 class="text-base font-semibold text-[var(--title-color)]">{t('toc.title')}</h3>
|
|
||||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
|
||||||
{t('toc.intro')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav id="toc-nav" class="space-y-2 max-h-[calc(100vh-240px)] overflow-y-auto pr-1 text-sm">
|
<div class="space-y-1.5 border-b border-[var(--border-color)]/62 pb-3">
|
||||||
<!-- TOC items will be generated by JavaScript -->
|
<h3 class="text-[1.02rem] font-semibold tracking-[0.01em] text-[var(--title-color)]">
|
||||||
</nav>
|
{t('toc.title')}
|
||||||
|
</h3>
|
||||||
|
<p class="text-[12.5px] leading-6 text-[var(--text-secondary)]">
|
||||||
|
{t('toc.intro')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav
|
||||||
|
id="toc-nav"
|
||||||
|
class="max-h-[calc(100vh-260px)] overflow-y-auto pr-1"
|
||||||
|
aria-label={t('toc.title')}
|
||||||
|
>
|
||||||
|
<!-- TOC items will be generated by JavaScript -->
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<style is:inline>
|
||||||
|
#toc-nav {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding-left: 0.1rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: color-mix(in oklab, var(--primary) 24%, transparent) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0.46rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
width: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
color-mix(in oklab, var(--border-color) 78%, transparent),
|
||||||
|
color-mix(in oklab, var(--primary) 10%, transparent)
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in oklab, var(--primary) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link {
|
||||||
|
box-sizing: border-box;
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0.7rem minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
gap: 0.7rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0.56rem 0.72rem 0.56rem 0.18rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link:hover {
|
||||||
|
background: color-mix(in oklab, var(--primary) 4%, var(--terminal-bg));
|
||||||
|
color: var(--title-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link.is-active {
|
||||||
|
background: color-mix(in oklab, var(--primary) 7%, var(--terminal-bg));
|
||||||
|
color: var(--primary);
|
||||||
|
box-shadow:
|
||||||
|
inset 2px 0 0 var(--primary),
|
||||||
|
0 0 0 1px color-mix(in oklab, var(--primary) 10%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link-sub {
|
||||||
|
margin-left: 0.85rem;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
padding-bottom: 0.45rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link-dot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.52rem;
|
||||||
|
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);
|
||||||
|
transition:
|
||||||
|
background-color 160ms ease,
|
||||||
|
transform 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link:hover .toc-link-dot {
|
||||||
|
background: color-mix(in oklab, var(--primary) 32%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
#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%);
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link-sub .toc-link-dot {
|
||||||
|
width: 0.38rem;
|
||||||
|
height: 0.38rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-left: 0.08rem;
|
||||||
|
background: color-mix(in oklab, var(--text-tertiary) 60%, var(--border-color));
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link-label {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-word;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link-sub .toc-link-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: color-mix(in oklab, var(--text-secondary) 88%, var(--text-tertiary));
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
|
||||||
|
#toc-nav .toc-link-sub.is-active .toc-link-label {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script is:inline>
|
<script is:inline>
|
||||||
(function() {
|
(function() {
|
||||||
function generateTOC() {
|
function generateTOC() {
|
||||||
@@ -59,7 +197,6 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
|||||||
}
|
}
|
||||||
|
|
||||||
tocNav.innerHTML = '';
|
tocNav.innerHTML = '';
|
||||||
|
|
||||||
headings.forEach((heading, index) => {
|
headings.forEach((heading, index) => {
|
||||||
if (!heading.id) {
|
if (!heading.id) {
|
||||||
heading.id = `heading-${index}`;
|
heading.id = `heading-${index}`;
|
||||||
@@ -67,12 +204,10 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
|||||||
|
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = `#${heading.id}`;
|
link.href = `#${heading.id}`;
|
||||||
link.className = `terminal-nav-link flex w-full items-center justify-between ${
|
link.className = heading.tagName === 'H3' ? 'toc-link toc-link-sub' : 'toc-link';
|
||||||
heading.tagName === 'H3' ? 'pl-8 text-xs' : 'text-sm'
|
|
||||||
}`;
|
|
||||||
link.innerHTML = `
|
link.innerHTML = `
|
||||||
<span class="truncate">${heading.textContent || ''}</span>
|
<span class="toc-link-dot" aria-hidden="true"></span>
|
||||||
<i class="fas fa-angle-right text-[10px] opacity-60"></i>
|
<span class="toc-link-label">${heading.textContent || ''}</span>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
link.addEventListener('click', (e) => {
|
link.addEventListener('click', (e) => {
|
||||||
@@ -90,8 +225,10 @@ const hasBeforeNav = Astro.slots.has('before-nav');
|
|||||||
const links = tocNav.querySelectorAll('a');
|
const links = tocNav.querySelectorAll('a');
|
||||||
links.forEach(link => {
|
links.forEach(link => {
|
||||||
link.classList.remove('is-active');
|
link.classList.remove('is-active');
|
||||||
|
link.removeAttribute('aria-current');
|
||||||
if (link.getAttribute('href') === `#${entry.target.id}`) {
|
if (link.getAttribute('href') === `#${entry.target.id}`) {
|
||||||
link.classList.add('is-active');
|
link.classList.add('is-active');
|
||||||
|
link.setAttribute('aria-current', 'true');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
---
|
---
|
||||||
|
import { resolvePublicApiBaseUrl } from '../../lib/api/client';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
pageType: string;
|
pageType: string;
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
@@ -9,11 +11,12 @@ const props = Astro.props;
|
|||||||
const pageType = props.pageType;
|
const pageType = props.pageType;
|
||||||
const entityId = props.entityId ?? '';
|
const entityId = props.entityId ?? '';
|
||||||
const postSlug = props.postSlug ?? '';
|
const postSlug = props.postSlug ?? '';
|
||||||
|
const analyticsEndpoint = `${resolvePublicApiBaseUrl(Astro.url)}/analytics/content`;
|
||||||
---
|
---
|
||||||
|
|
||||||
<script is:inline define:vars={{ pageType, entityId, postSlug }}>
|
<script is:inline define:vars={{ analyticsEndpoint, pageType, entityId, postSlug }}>
|
||||||
(() => {
|
(() => {
|
||||||
const endpoint = '/api/analytics/content';
|
const endpoint = analyticsEndpoint;
|
||||||
const storageKey = `termi:pageview:${pageType}:${entityId || postSlug || 'root'}`;
|
const storageKey = `termi:pageview:${pageType}:${entityId || postSlug || 'root'}`;
|
||||||
|
|
||||||
function ensureSessionId() {
|
function ensureSessionId() {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface Props {
|
|||||||
description?: string;
|
description?: string;
|
||||||
stats?: ShareStat[];
|
stats?: ShareStat[];
|
||||||
wechatShareQrEnabled?: boolean;
|
wechatShareQrEnabled?: boolean;
|
||||||
|
variant?: 'default' | 'compact';
|
||||||
}
|
}
|
||||||
|
|
||||||
const { locale, t } = getI18n(Astro);
|
const { locale, t } = getI18n(Astro);
|
||||||
@@ -33,7 +34,9 @@ const {
|
|||||||
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
|
: '复制链接、摘要或二维码,方便换设备继续看,也方便直接发给别人。',
|
||||||
stats = [],
|
stats = [],
|
||||||
wechatShareQrEnabled = false,
|
wechatShareQrEnabled = false,
|
||||||
|
variant = 'default',
|
||||||
} = Astro.props as Props;
|
} = Astro.props as Props;
|
||||||
|
const isCompact = variant === 'compact';
|
||||||
|
|
||||||
const visibleBadge = badge;
|
const visibleBadge = badge;
|
||||||
const visibleTitle = title;
|
const visibleTitle = title;
|
||||||
@@ -136,10 +139,13 @@ if (wechatShareQrEnabled) {
|
|||||||
---
|
---
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="rounded-[28px] 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))] p-5 sm:p-6"
|
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))]',
|
||||||
|
isCompact ? 'rounded-[24px] p-4' : 'rounded-[28px] p-5 sm:p-6',
|
||||||
|
]}
|
||||||
data-share-panel-id={panelId}
|
data-share-panel-id={panelId}
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div class:list={['flex flex-col gap-4', !isCompact && 'lg:flex-row lg:items-start lg:justify-between']}>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
||||||
@@ -149,13 +155,17 @@ if (wechatShareQrEnabled) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<h3 class="text-xl font-semibold text-[var(--title-color)]">{visibleTitle}</h3>
|
<h3 class:list={['font-semibold text-[var(--title-color)]', isCompact ? 'text-lg' : 'text-xl']}>
|
||||||
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{visibleDescription}</p>
|
{visibleTitle}
|
||||||
|
</h3>
|
||||||
|
<p class:list={['text-sm text-[var(--text-secondary)]', isCompact ? 'leading-6' : 'max-w-3xl leading-7']}>
|
||||||
|
{visibleDescription}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{stats.length > 0 ? (
|
{stats.length > 0 ? (
|
||||||
<div class="grid gap-3 sm:grid-cols-2 lg:min-w-[16rem]">
|
<div class:list={['grid gap-3 sm:grid-cols-2', !isCompact && 'lg:min-w-[16rem]']}>
|
||||||
{stats.slice(0, 4).map((item) => (
|
{stats.slice(0, 4).map((item) => (
|
||||||
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/76 px-4 py-3">
|
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/76 px-4 py-3">
|
||||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||||
@@ -166,9 +176,14 @@ if (wechatShareQrEnabled) {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_16px_40px_rgba(15,23,42,0.06)]">
|
<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)]',
|
||||||
|
isCompact ? 'mt-4 p-4' : 'mt-5 p-5',
|
||||||
|
]}>
|
||||||
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
|
<div class="text-[11px] uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{copy.summaryTitle}</div>
|
||||||
<p class="mt-3 text-base leading-8 text-[var(--title-color)]">{safeSummary}</p>
|
<p class:list={['mt-3 text-[var(--title-color)]', isCompact ? 'text-sm leading-7 line-clamp-4' : 'text-base leading-8']}>
|
||||||
|
{safeSummary}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
<div class="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
@@ -217,7 +232,7 @@ if (wechatShareQrEnabled) {
|
|||||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||||
{isEnglish ? 'Share channels' : '分享渠道'}
|
{isEnglish ? 'Share channels' : '分享渠道'}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>
|
{!isCompact && <p class="text-xs leading-6 text-[var(--text-secondary)]">{visibleDescription}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -29,10 +29,12 @@ export function stripMarkdown(value: string): string {
|
|||||||
.replace(/^---[\s\S]*?---/, ' ')
|
.replace(/^---[\s\S]*?---/, ' ')
|
||||||
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||||
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||||
.replace(/`{1,3}[^`]*`{1,3}/g, ' ')
|
.replace(/```[\s\S]*?```/g, ' ')
|
||||||
|
.replace(/`([^`\n]+)`/g, '$1')
|
||||||
.replace(/^#{1,6}\s+/gm, '')
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
.replace(/^\s*[-*+]\s+/gm, '')
|
.replace(/^\s*[-*+]\s+/gm, '')
|
||||||
.replace(/^\s*\d+\.\s+/gm, '')
|
.replace(/^\s*\d+\.\s+/gm, '')
|
||||||
|
.replace(/<[^>]+>/g, ' ')
|
||||||
.replace(/[>*_~|]/g, ' ')
|
.replace(/[>*_~|]/g, ' ')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,6 +105,46 @@ export function buildArticleHighlights(
|
|||||||
return sentences.slice(0, limit).map((item) => truncate(item, 88));
|
return sentences.slice(0, limit).map((item) => truncate(item, 88));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildArticlePreviewParagraphs(
|
||||||
|
post: Pick<Post, 'title' | 'description' | 'content'>,
|
||||||
|
maxParagraphs = 3,
|
||||||
|
maxLength = 118,
|
||||||
|
): string[] {
|
||||||
|
const descriptionText = normalizeWhitespace(post.description || '');
|
||||||
|
const rawParagraphs = (post.content || '')
|
||||||
|
.replace(/\r\n/g, '\n')
|
||||||
|
.split(/\n{2,}/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const paragraphs = uniqueNonEmpty(
|
||||||
|
rawParagraphs
|
||||||
|
.filter((item) => !/^\s*#{1,6}\s+/.test(item))
|
||||||
|
.filter((item) => !/^\s*(?:[-*+]|\d+\.)\s+/.test(item))
|
||||||
|
.filter((item) => !/^\s*```/.test(item))
|
||||||
|
.map((item) => stripMarkdown(item))
|
||||||
|
.filter((item) => {
|
||||||
|
const normalized = normalizeWhitespace(item);
|
||||||
|
return (
|
||||||
|
normalized &&
|
||||||
|
normalized !== normalizeWhitespace(post.title) &&
|
||||||
|
normalized !== descriptionText &&
|
||||||
|
normalized.length >= 18
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const previewParagraphs = paragraphs.slice(0, maxParagraphs).map((item) => truncate(item, maxLength));
|
||||||
|
|
||||||
|
if (previewParagraphs.length > 0) {
|
||||||
|
return previewParagraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueNonEmpty([post.description].filter(Boolean))
|
||||||
|
.slice(0, 1)
|
||||||
|
.map((item) => truncate(item, maxLength));
|
||||||
|
}
|
||||||
|
|
||||||
export function resolvePostUpdatedAt(post: Pick<Post, 'updatedAt' | 'publishAt' | 'createdAt' | 'date'>): string {
|
export function resolvePostUpdatedAt(post: Pick<Post, 'updatedAt' | 'publishAt' | 'createdAt' | 'date'>): string {
|
||||||
return post.updatedAt || post.publishAt || post.createdAt || post.date;
|
return post.updatedAt || post.publishAt || post.createdAt || post.date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,16 @@ import CodeCopyButton from '../../components/CodeCopyButton.astro';
|
|||||||
import Comments from '../../components/Comments.astro';
|
import Comments from '../../components/Comments.astro';
|
||||||
import ParagraphComments from '../../components/ParagraphComments.astro';
|
import ParagraphComments from '../../components/ParagraphComments.astro';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { apiClient, DEFAULT_SITE_SETTINGS } from '../../lib/api/client';
|
import {
|
||||||
|
apiClient,
|
||||||
|
DEFAULT_SITE_SETTINGS,
|
||||||
|
resolvePublicApiBaseUrl,
|
||||||
|
} from '../../lib/api/client';
|
||||||
import { formatReadTime, getI18n } from '../../lib/i18n';
|
import { formatReadTime, getI18n } from '../../lib/i18n';
|
||||||
import {
|
import {
|
||||||
buildArticleFaqs,
|
buildArticleFaqs,
|
||||||
buildArticleHighlights,
|
buildArticleHighlights,
|
||||||
|
buildArticlePreviewParagraphs,
|
||||||
buildArticleSynopsis,
|
buildArticleSynopsis,
|
||||||
resolvePostUpdatedAt,
|
resolvePostUpdatedAt,
|
||||||
} from '../../lib/seo';
|
} from '../../lib/seo';
|
||||||
@@ -38,6 +43,7 @@ const { slug } = Astro.params;
|
|||||||
|
|
||||||
let post = null;
|
let post = null;
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
|
const analyticsEndpoint = `${resolvePublicApiBaseUrl(Astro.url)}/analytics/content`;
|
||||||
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
|
let homeData: Awaited<ReturnType<typeof apiClient.getHomePageData>> | null = null;
|
||||||
let postLookupFailed = false;
|
let postLookupFailed = false;
|
||||||
|
|
||||||
@@ -192,6 +198,7 @@ const updatedAtLabel = Number.isNaN(new Date(updatedAt).valueOf())
|
|||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
});
|
});
|
||||||
const articleSynopsis = buildArticleSynopsis(post, 220);
|
const articleSynopsis = buildArticleSynopsis(post, 220);
|
||||||
|
const articlePreviewParagraphs = buildArticlePreviewParagraphs(post, 3, 110);
|
||||||
const articleHighlights = buildArticleHighlights(post, 3);
|
const articleHighlights = buildArticleHighlights(post, 3);
|
||||||
const articleFaqs = buildArticleFaqs(post, {
|
const articleFaqs = buildArticleFaqs(post, {
|
||||||
locale,
|
locale,
|
||||||
@@ -208,8 +215,8 @@ const digestStats = [
|
|||||||
value: String(articleHighlights.length || 1),
|
value: String(articleHighlights.length || 1),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: articleCopy.faqCount,
|
label: articleCopy.keywords,
|
||||||
value: String(articleFaqs.length),
|
value: String(post.tags?.length || 0),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const articleDigestClipboardText = [
|
const articleDigestClipboardText = [
|
||||||
@@ -376,8 +383,8 @@ const breadcrumbJsonLd = {
|
|||||||
<Lightbox />
|
<Lightbox />
|
||||||
<CodeCopyButton />
|
<CodeCopyButton />
|
||||||
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-article-slug={post.slug}>
|
<div class="mx-auto max-w-[1660px] px-4 py-8 sm:px-6 lg:px-8" data-article-slug={post.slug}>
|
||||||
<div class="flex flex-col gap-8 lg:flex-row">
|
<div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_17.5rem] 2xl:grid-cols-[minmax(0,1fr)_18.5rem]">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<TerminalWindow title={`~/articles/${post.slug}`} class="w-full">
|
<TerminalWindow title={`~/articles/${post.slug}`} class="w-full">
|
||||||
<div class="px-4 pb-2">
|
<div class="px-4 pb-2">
|
||||||
@@ -430,12 +437,12 @@ const breadcrumbJsonLd = {
|
|||||||
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{post.description}</p>
|
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{post.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section 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">
|
<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="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div>
|
<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="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div>
|
<div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div>
|
||||||
<div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
|
<div class="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div>
|
||||||
<div class="relative grid gap-5 xl:grid-cols-[minmax(0,1.4fr)_minmax(19rem,0.95fr)]">
|
<div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
|
||||||
<div class="space-y-5">
|
<div class="relative space-y-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
||||||
<i class="fas fa-sparkles text-[10px]"></i>
|
<i class="fas fa-sparkles text-[10px]"></i>
|
||||||
@@ -453,7 +460,11 @@ const breadcrumbJsonLd = {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
|
<div class="rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
|
||||||
<p class="text-base leading-8 text-[var(--title-color)]">{articleSynopsis}</p>
|
<div class="space-y-3">
|
||||||
|
{articlePreviewParagraphs.map((paragraph) => (
|
||||||
|
<p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||||
@@ -489,121 +500,124 @@ const breadcrumbJsonLd = {
|
|||||||
></p>
|
></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
|
<div class="grid gap-3 xl:grid-cols-[minmax(0,1.18fr)_minmax(13rem,0.82fr)]">
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
|
||||||
<div class="space-y-1">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
<div class="space-y-1">
|
||||||
{articleCopy.shareChannelsTitle}
|
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||||
</p>
|
{articleCopy.shareChannelsTitle}
|
||||||
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
</p>
|
||||||
{articleCopy.shareChannelsDescription}
|
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
||||||
</p>
|
{articleCopy.shareChannelsDescription}
|
||||||
</div>
|
</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
</div>
|
||||||
<a
|
<div class="flex flex-wrap gap-2">
|
||||||
href={xShareUrl}
|
<a
|
||||||
target="_blank"
|
href={xShareUrl}
|
||||||
rel="noopener noreferrer nofollow"
|
target="_blank"
|
||||||
class="terminal-action-button"
|
rel="noopener noreferrer nofollow"
|
||||||
data-article-share-link
|
|
||||||
>
|
|
||||||
<i class="fab fa-twitter"></i>
|
|
||||||
<span>{articleCopy.shareToX}</span>
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href={telegramShareUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer nofollow"
|
|
||||||
class="terminal-action-button"
|
|
||||||
data-article-share-link
|
|
||||||
>
|
|
||||||
<i class="fab fa-telegram-plane"></i>
|
|
||||||
<span>{articleCopy.shareToTelegram}</span>
|
|
||||||
</a>
|
|
||||||
{wechatShareQrEnabled && wechatShareQrSvg && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="terminal-action-button"
|
class="terminal-action-button"
|
||||||
data-article-wechat-qr-open
|
data-article-share-link
|
||||||
>
|
>
|
||||||
<i class="fab fa-weixin"></i>
|
<i class="fab fa-twitter"></i>
|
||||||
<span>{articleCopy.shareToWeChat}</span>
|
<span>{articleCopy.shareToX}</span>
|
||||||
</button>
|
</a>
|
||||||
)}
|
<a
|
||||||
|
href={telegramShareUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer nofollow"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-article-share-link
|
||||||
|
>
|
||||||
|
<i class="fab fa-telegram-plane"></i>
|
||||||
|
<span>{articleCopy.shareToTelegram}</span>
|
||||||
|
</a>
|
||||||
|
{wechatShareQrEnabled && wechatShareQrSvg && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="terminal-action-button"
|
||||||
|
data-article-wechat-qr-open
|
||||||
|
>
|
||||||
|
<i class="fab fa-weixin"></i>
|
||||||
|
<span>{articleCopy.shareToWeChat}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||||
{digestStats.map((item) => (
|
{digestStats.map((item) => (
|
||||||
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 px-4 py-3">
|
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 px-4 py-3">
|
||||||
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{item.label}</div>
|
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{item.label}</div>
|
||||||
<div class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
|
<div class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</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)]">
|
||||||
|
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||||
|
{articleCopy.sourceTitle}
|
||||||
|
</h3>
|
||||||
|
<dl class="mt-4 space-y-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.updated}</dt>
|
||||||
|
<dd class="mt-1 text-[var(--title-color)]">{updatedAtLabel}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.category}</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<a
|
||||||
|
href={buildCategoryUrl(post.category)}
|
||||||
|
class="text-[var(--title-color)] underline decoration-dotted underline-offset-4 hover:text-[var(--primary)]"
|
||||||
|
>
|
||||||
|
{post.category}
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.canonical}</dt>
|
||||||
|
<dd class="mt-1 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.keywords}</dt>
|
||||||
|
<dd class="mt-2 flex flex-wrap gap-2">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<a
|
||||||
|
href={buildTagUrl(tag)}
|
||||||
|
class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs"
|
||||||
|
style={getAccentVars(getTagTheme(tag))}
|
||||||
|
>
|
||||||
|
<i class="fas fa-hashtag text-[10px]"></i>
|
||||||
|
{tag}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
{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-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
||||||
{articleCopy.sourceTitle}
|
{articleCopy.highlightsTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<dl class="mt-4 space-y-4 text-sm">
|
<div class="mt-4 space-y-3">
|
||||||
<div>
|
{articleHighlights.map((item, index) => (
|
||||||
<dt class="text-[var(--text-tertiary)]">{articleCopy.updated}</dt>
|
<div class="flex items-start gap-3 rounded-2xl border border-[var(--border-color)]/80 bg-[var(--bg)]/58 px-4 py-3">
|
||||||
<dd class="mt-1 text-[var(--title-color)]">{updatedAtLabel}</dd>
|
<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)]">
|
||||||
</div>
|
{index + 1}
|
||||||
<div>
|
</span>
|
||||||
<dt class="text-[var(--text-tertiary)]">{articleCopy.category}</dt>
|
<p class="text-sm leading-7 text-[var(--title-color)]">{item}</p>
|
||||||
<dd class="mt-1">
|
</div>
|
||||||
<a
|
))}
|
||||||
href={buildCategoryUrl(post.category)}
|
|
||||||
class="text-[var(--title-color)] underline decoration-dotted underline-offset-4 hover:text-[var(--primary)]"
|
|
||||||
>
|
|
||||||
{post.category}
|
|
||||||
</a>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-[var(--text-tertiary)]">{articleCopy.canonical}</dt>
|
|
||||||
<dd class="mt-1 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt class="text-[var(--text-tertiary)]">{articleCopy.keywords}</dt>
|
|
||||||
<dd class="mt-2 flex flex-wrap gap-2">
|
|
||||||
{post.tags.map((tag) => (
|
|
||||||
<a
|
|
||||||
href={buildTagUrl(tag)}
|
|
||||||
class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs"
|
|
||||||
style={getAccentVars(getTagTheme(tag))}
|
|
||||||
>
|
|
||||||
<i class="fas fa-hashtag text-[10px]"></i>
|
|
||||||
{tag}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{articleFaqs.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)]">
|
|
||||||
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
|
||||||
{articleCopy.faqTitle}
|
|
||||||
</h3>
|
|
||||||
<div class="mt-4 space-y-3">
|
|
||||||
{articleFaqs.map((item) => (
|
|
||||||
<div class="rounded-2xl border border-[var(--border-color)]/80 bg-[var(--bg)]/58 px-4 py-3">
|
|
||||||
<p class="font-semibold text-[var(--title-color)]">{item.question}</p>
|
|
||||||
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{item.answer}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -979,6 +993,7 @@ const breadcrumbJsonLd = {
|
|||||||
<script
|
<script
|
||||||
is:inline
|
is:inline
|
||||||
define:vars={{
|
define:vars={{
|
||||||
|
analyticsEndpoint,
|
||||||
postSlug: post.slug,
|
postSlug: post.slug,
|
||||||
articleDigestClipboardText,
|
articleDigestClipboardText,
|
||||||
articleDigestShareText,
|
articleDigestShareText,
|
||||||
@@ -989,7 +1004,7 @@ const breadcrumbJsonLd = {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
(() => {
|
(() => {
|
||||||
const endpoint = '/api/analytics/content';
|
const endpoint = analyticsEndpoint;
|
||||||
const sessionStorageKey = `termi:content-session:${postSlug}`;
|
const sessionStorageKey = `termi:content-session:${postSlug}`;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
let sentPageView = false;
|
let sentPageView = false;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import TerminalWindow from '../components/ui/TerminalWindow.astro';
|
|||||||
import CommandPrompt from '../components/ui/CommandPrompt.astro';
|
import CommandPrompt from '../components/ui/CommandPrompt.astro';
|
||||||
import FilterPill from '../components/ui/FilterPill.astro';
|
import FilterPill from '../components/ui/FilterPill.astro';
|
||||||
import PostCard from '../components/PostCard.astro';
|
import PostCard from '../components/PostCard.astro';
|
||||||
import FriendLinkCard from '../components/FriendLinkCard.astro';
|
|
||||||
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
|
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
|
||||||
import StatsList from '../components/StatsList.astro';
|
import StatsList from '../components/StatsList.astro';
|
||||||
import TechStackList from '../components/TechStackList.astro';
|
import TechStackList from '../components/TechStackList.astro';
|
||||||
@@ -28,6 +27,7 @@ const selectedCategory = url.searchParams.get('category') || '';
|
|||||||
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
|
||||||
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
|
||||||
const previewLimit = 5;
|
const previewLimit = 5;
|
||||||
|
const popularPreviewLimit = 4;
|
||||||
const DEFAULT_HOME_RANGE_KEY = '7d';
|
const DEFAULT_HOME_RANGE_KEY = '7d';
|
||||||
|
|
||||||
let siteSettings = DEFAULT_SITE_SETTINGS;
|
let siteSettings = DEFAULT_SITE_SETTINGS;
|
||||||
@@ -166,6 +166,18 @@ const popularRangeOptions = contentRanges.map((range) => ({
|
|||||||
const initialPopularCount = popularRangeCards.filter(
|
const initialPopularCount = popularRangeCards.filter(
|
||||||
({ rangeKey, post }) => rangeKey === activeContentRange.key && matchesSelectedFilters(post),
|
({ rangeKey, post }) => rangeKey === activeContentRange.key && matchesSelectedFilters(post),
|
||||||
).length;
|
).length;
|
||||||
|
const initialPopularVisibleKeys = new Set(
|
||||||
|
popularRangeCards
|
||||||
|
.filter(({ rangeKey, post }) => rangeKey === activeContentRange.key && matchesSelectedFilters(post))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const scoreDiff = right.item.pageViews - left.item.pageViews;
|
||||||
|
if (scoreDiff !== 0) return scoreDiff;
|
||||||
|
return right.item.readCompletes - left.item.readCompletes;
|
||||||
|
})
|
||||||
|
.slice(0, popularPreviewLimit)
|
||||||
|
.map(({ rangeKey, post }) => `${rangeKey}:${post.slug}`),
|
||||||
|
);
|
||||||
|
const sidebarFriendLinks = friendLinks.slice(0, 3);
|
||||||
const buildHomeUrl = ({
|
const buildHomeUrl = ({
|
||||||
type = selectedType,
|
type = selectedType,
|
||||||
tag = selectedTag,
|
tag = selectedTag,
|
||||||
@@ -210,11 +222,6 @@ const postsPrompt = hasActiveFilters
|
|||||||
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
|
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
|
||||||
: t('home.promptPostsDefault', { count: previewCount });
|
: t('home.promptPostsDefault', { count: previewCount });
|
||||||
const popularPrompt = t('home.promptPopularRange', { label: activeContentRange.label });
|
const popularPrompt = t('home.promptPopularRange', { label: activeContentRange.label });
|
||||||
const popularSortOptions = [
|
|
||||||
{ id: 'views', label: t('home.sortByViews'), icon: 'fa-eye' },
|
|
||||||
{ id: 'completes', label: t('home.sortByCompletes'), icon: 'fa-check-double' },
|
|
||||||
{ id: 'depth', label: t('home.sortByDepth'), icon: 'fa-chart-line' },
|
|
||||||
];
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
|
||||||
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
|
||||||
@@ -249,11 +256,34 @@ const homeShareCopy = isEnglish
|
|||||||
description:
|
description:
|
||||||
'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
|
'Use the homepage as the canonical top-level entry for people and AI search to branch into articles, taxonomies, reviews, and profile context.',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
badge: '首页',
|
badge: '首页',
|
||||||
title: '分享首页',
|
title: '分享首页',
|
||||||
description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。',
|
description: '把首页发给别人,能快速看到文章、分类、评测和个人介绍等主要内容。',
|
||||||
};
|
};
|
||||||
|
const homeSidebarCopy = isEnglish
|
||||||
|
? {
|
||||||
|
quickLinks: 'Quick links',
|
||||||
|
quickLinksDesc: 'Jump to the main sections of the site.',
|
||||||
|
popularTitle: 'Hot now',
|
||||||
|
popularDesc: 'Track the most-read content in the selected window.',
|
||||||
|
friendsTitle: 'Friend links',
|
||||||
|
friendsDesc: 'A few recommended sites from the blogroll.',
|
||||||
|
statsTitle: 'Site stats',
|
||||||
|
statsDesc: 'A compact snapshot of current site scale.',
|
||||||
|
aiBriefTitle: 'AI site brief',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
quickLinks: '快速入口',
|
||||||
|
quickLinksDesc: '把常用入口收进侧栏,首页阅读流更清爽。',
|
||||||
|
popularTitle: '最近热门',
|
||||||
|
popularDesc: '按当前时间窗口查看最受关注的内容。',
|
||||||
|
friendsTitle: '友情链接',
|
||||||
|
friendsDesc: '先看几个常访问的站点入口。',
|
||||||
|
statsTitle: '站点概览',
|
||||||
|
statsDesc: '快速看一下当前站点规模与内容状态。',
|
||||||
|
aiBriefTitle: '站点摘要',
|
||||||
|
};
|
||||||
const homeBriefHighlights = buildDiscoveryHighlights([
|
const homeBriefHighlights = buildDiscoveryHighlights([
|
||||||
siteSettings.siteDescription,
|
siteSettings.siteDescription,
|
||||||
siteSettings.heroSubtitle,
|
siteSettings.heroSubtitle,
|
||||||
@@ -287,7 +317,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
|||||||
jsonLd={[...homeJsonLd, homeFaqJsonLd].filter(Boolean)}
|
jsonLd={[...homeJsonLd, homeFaqJsonLd].filter(Boolean)}
|
||||||
>
|
>
|
||||||
<PageViewTracker pageType="home" entityId="homepage" />
|
<PageViewTracker pageType="home" entityId="homepage" />
|
||||||
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div class="mx-auto max-w-[1480px] px-4 py-6 sm:px-6 lg:px-8">
|
||||||
<TerminalWindow title={terminalConfig.title} class="w-full">
|
<TerminalWindow title={terminalConfig.title} class="w-full">
|
||||||
<div class="mb-5 px-4 overflow-x-auto">
|
<div class="mb-5 px-4 overflow-x-auto">
|
||||||
<pre class="font-mono text-xs sm:text-sm text-[var(--primary)] whitespace-pre">{terminalConfig.asciiArt}</pre>
|
<pre class="font-mono text-xs sm:text-sm text-[var(--primary)] whitespace-pre">{terminalConfig.asciiArt}</pre>
|
||||||
@@ -321,39 +351,6 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-4 mt-4 home-nav-strip">
|
|
||||||
{navLinks.map(link => (
|
|
||||||
<a href={link.href} class="home-nav-pill">
|
|
||||||
<i class={`fas ${link.icon} text-[11px]`}></i>
|
|
||||||
<span class="min-w-0 truncate">{link.text}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ml-4 mt-4">
|
|
||||||
<SharePanel
|
|
||||||
shareTitle={siteSettings.siteTitle}
|
|
||||||
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
|
|
||||||
canonicalUrl={siteBaseUrl}
|
|
||||||
badge={homeShareCopy.badge}
|
|
||||||
kicker="geo / homepage"
|
|
||||||
title={homeShareCopy.title}
|
|
||||||
description={homeShareCopy.description}
|
|
||||||
stats={systemStats.slice(0, 4)}
|
|
||||||
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="ml-4 mt-4">
|
|
||||||
<DiscoveryBrief
|
|
||||||
badge={isEnglish ? 'site brief' : '站点摘要'}
|
|
||||||
kicker="geo / overview"
|
|
||||||
title={isEnglish ? 'AI-readable site brief' : '给 AI 看的站点摘要'}
|
|
||||||
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
|
|
||||||
highlights={homeBriefHighlights}
|
|
||||||
faqs={homeFaqs}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{apiError && (
|
{apiError && (
|
||||||
@@ -364,397 +361,431 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div id="discover" class="mb-6 px-4">
|
<div class="grid gap-6 px-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
|
<div class="min-w-0 space-y-6">
|
||||||
<div class="ml-4 terminal-panel home-discovery-shell">
|
<div id="discover">
|
||||||
<div class="home-discovery-head">
|
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
|
||||||
<div class="home-taxonomy-copy">
|
<div class="ml-4 terminal-panel home-discovery-shell">
|
||||||
<span class="terminal-kicker">
|
<div class="home-discovery-head">
|
||||||
<i class="fas fa-sliders-h"></i>
|
<div class="home-taxonomy-copy">
|
||||||
<span>home filters</span>
|
<span class="terminal-kicker">
|
||||||
</span>
|
<i class="fas fa-sliders-h"></i>
|
||||||
<div>
|
<span>home filters</span>
|
||||||
<h2 class="text-xl font-bold text-[var(--title-color)]">{t('common.browsePosts')}</h2>
|
</span>
|
||||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
<div>
|
||||||
{t('common.categoriesCount', { count: categories.length })} · {t('common.tagsCount', { count: tags.length })} · <span id="home-discovery-results">{t('common.resultsCount', { count: filteredPostsCount })}</span>
|
<h2 class="text-xl font-bold text-[var(--title-color)]">{t('common.browsePosts')}</h2>
|
||||||
</p>
|
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
||||||
</div>
|
{t('common.categoriesCount', { count: categories.length })} · {t('common.tagsCount', { count: tags.length })} · <span id="home-discovery-results">{t('common.resultsCount', { count: filteredPostsCount })}</span>
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div class="home-discovery-actions">
|
|
||||||
<a id="home-clear-filters" href="/" class:list={['terminal-link-arrow', !hasActiveFilters && 'hidden']}>
|
|
||||||
<span>{t('common.clearFilters')}</span>
|
|
||||||
<i class="fas fa-rotate-left text-xs"></i>
|
|
||||||
</a>
|
|
||||||
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} command="cd ~/articles" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="home-filter-toolbar">
|
|
||||||
{postTypeFilters.map(filter => (
|
|
||||||
<FilterPill
|
|
||||||
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
|
||||||
active={selectedType === filter.id}
|
|
||||||
href={buildHomeUrl({ type: filter.id, category: selectedCategory, tag: selectedTag })}
|
|
||||||
data-home-type-filter={filter.id}
|
|
||||||
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
|
||||||
>
|
|
||||||
<i class={`fas ${filter.icon}`}></i>
|
|
||||||
<span>{filter.name}</span>
|
|
||||||
</FilterPill>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="home-active-filters" class:list={['home-active-filter-row', !hasActiveFilters && 'hidden']}>
|
|
||||||
<span id="home-active-type" class:list={['terminal-chip max-w-full min-w-0', selectedType === 'all' && 'hidden']}>
|
|
||||||
<i id="home-active-type-icon" class={`fas ${postTypeFilters.find((item) => item.id === selectedType)?.icon || 'fa-stream'} text-[10px]`}></i>
|
|
||||||
<span id="home-active-type-text" class="min-w-0 truncate">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
id="home-active-category"
|
|
||||||
class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedCategory && 'hidden']}
|
|
||||||
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
|
|
||||||
>
|
|
||||||
<i class="fas fa-folder-open text-[10px]"></i>
|
|
||||||
<span id="home-active-category-text" class="min-w-0 truncate">{selectedCategory}</span>
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
id="home-active-tag"
|
|
||||||
class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedTag && 'hidden']}
|
|
||||||
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
|
|
||||||
>
|
|
||||||
<i class="fas fa-hashtag text-[10px]"></i>
|
|
||||||
<span id="home-active-tag-text" class="min-w-0 truncate">{selectedTag}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="home-discovery-grid">
|
|
||||||
<section id="categories" class="home-discovery-panel">
|
|
||||||
<div class="home-discovery-panel__head">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--title-color)]">{t('nav.categories')}</h3>
|
|
||||||
<span class="terminal-stat-pill">{categories.length}</span>
|
|
||||||
</div>
|
|
||||||
<div class="home-category-grid home-category-grid--compact">
|
|
||||||
{categories.map(category => (
|
|
||||||
<a
|
|
||||||
href={buildHomeUrl({
|
|
||||||
category: normalizedSelectedCategory === category.name.trim().toLowerCase() ? '' : category.name,
|
|
||||||
tag: selectedTag,
|
|
||||||
})}
|
|
||||||
data-home-category-filter={category.name}
|
|
||||||
class:list={[
|
|
||||||
'home-category-card terminal-panel-accent terminal-interactive-card group',
|
|
||||||
normalizedSelectedCategory === category.name.trim().toLowerCase() && 'is-active'
|
|
||||||
]}
|
|
||||||
style={getAccentVars(getCategoryTheme(category.name))}
|
|
||||||
>
|
|
||||||
<div class="home-category-card__icon terminal-accent-icon">
|
|
||||||
<i class="fas fa-folder-open"></i>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex flex-1 items-center justify-between gap-3">
|
|
||||||
<h3 class="truncate text-sm font-semibold text-[var(--title-color)] transition-colors group-hover:text-[var(--accent-color,var(--primary))]">
|
|
||||||
{category.name}
|
|
||||||
</h3>
|
|
||||||
<span class="terminal-stat-pill terminal-stat-pill--accent shrink-0" style={getAccentVars(getCategoryTheme(category.name))}>
|
|
||||||
<span>{category.count ?? 0}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section id="tags" class="home-discovery-panel home-tag-shell">
|
|
||||||
<div class="home-discovery-panel__head">
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--title-color)]">{t('nav.tags')}</h3>
|
|
||||||
<span class="terminal-stat-pill">{tags.length}</span>
|
|
||||||
</div>
|
|
||||||
<div class="home-tag-cloud">
|
|
||||||
{tagEntries.map((tag) => (
|
|
||||||
<a
|
|
||||||
href={buildHomeUrl({
|
|
||||||
tag: normalizedSelectedTag === tag.name.trim().toLowerCase() ? '' : tag.name,
|
|
||||||
category: selectedCategory,
|
|
||||||
})}
|
|
||||||
data-home-tag-filter={tag.name}
|
|
||||||
class:list={[
|
|
||||||
'home-tag-cloud__item',
|
|
||||||
normalizedSelectedTag === tag.name.trim().toLowerCase() && 'is-active'
|
|
||||||
]}
|
|
||||||
style={getTagCloudStyle(tag.name)}
|
|
||||||
>
|
|
||||||
<span class="home-tag-cloud__hash">#</span>
|
|
||||||
<span>{tag.name}</span>
|
|
||||||
<span class="home-tag-cloud__count">{tag.count}</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pinnedPost && (
|
|
||||||
<div class="mb-6 px-4">
|
|
||||||
<CommandPrompt command={t('home.promptPinned')} />
|
|
||||||
<div id="home-pinned-wrap" class:list={['ml-4', !initialPinnedVisible && 'hidden']}>
|
|
||||||
<div class="terminal-panel terminal-panel-accent terminal-interactive-card p-4" style={getAccentVars(getPostTypeTheme(pinnedPost.type))}>
|
|
||||||
<div class="flex items-center gap-2 mb-2">
|
|
||||||
<span class="px-2 py-0.5 text-xs rounded bg-[var(--primary)] text-[var(--terminal-bg)] font-bold">{t('home.pinned')}</span>
|
|
||||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(pinnedPost.type))}>
|
|
||||||
{pinnedPost.type === 'article' ? t('common.article') : t('common.tweet')}
|
|
||||||
</span>
|
|
||||||
<a href={`/articles/${pinnedPost.slug}`} class="text-lg font-bold text-[var(--title-color)] transition hover:text-[var(--primary)]">
|
|
||||||
{pinnedPost.title}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-[var(--text-secondary)] mb-2">{pinnedPost.date} | {t('common.readTime')}: {formatReadTime(locale, pinnedPost.readTime, t)}</p>
|
|
||||||
<p class="text-[var(--text-secondary)]">{pinnedPost.description}</p>
|
|
||||||
<div class="mt-4">
|
|
||||||
<a href={`/articles/${pinnedPost.slug}`} class="terminal-action-button inline-flex">
|
|
||||||
<i class="fas fa-angle-right"></i>
|
|
||||||
<span>{t('common.readMore')}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div id="posts" class="mb-10 px-4">
|
|
||||||
<CommandPrompt promptId="home-posts-prompt" command={postsPrompt} />
|
|
||||||
<div class="ml-4">
|
|
||||||
{allPosts.length > 0 ? (
|
|
||||||
<div id="home-posts-list" class="divide-y divide-[var(--border-color)]">
|
|
||||||
{allPosts.map((post) => {
|
|
||||||
const matchesCurrentFilter = matchesSelectedFilters(post);
|
|
||||||
const filteredIndex = matchesCurrentFilter
|
|
||||||
? allPosts.filter(matchesSelectedFilters).findIndex((item) => item.slug === post.slug)
|
|
||||||
: -1;
|
|
||||||
const isVisible = matchesCurrentFilter && filteredIndex < previewLimit;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-home-post-card
|
|
||||||
data-home-type={post.type}
|
|
||||||
data-home-category={post.category?.trim().toLowerCase() || ''}
|
|
||||||
data-home-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
|
|
||||||
data-home-pinned={post.pinned ? 'true' : 'false'}
|
|
||||||
data-home-slug={post.slug}
|
|
||||||
class:list={[!isVisible && 'hidden']}
|
|
||||||
>
|
|
||||||
<PostCard post={post} selectedTag={selectedTag} tagHrefPrefix={homeTagHrefPrefix} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
|
||||||
|
<div class="home-discovery-actions">
|
||||||
|
<a id="home-clear-filters" href="/" class:list={['terminal-link-arrow', !hasActiveFilters && 'hidden']}>
|
||||||
|
<span>{t('common.clearFilters')}</span>
|
||||||
|
<i class="fas fa-rotate-left text-xs"></i>
|
||||||
|
</a>
|
||||||
|
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} command="cd ~/articles" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-filter-toolbar">
|
||||||
|
{postTypeFilters.map(filter => (
|
||||||
|
<FilterPill
|
||||||
|
tone={filter.id === 'all' ? 'neutral' : 'accent'}
|
||||||
|
active={selectedType === filter.id}
|
||||||
|
href={buildHomeUrl({ type: filter.id, category: selectedCategory, tag: selectedTag })}
|
||||||
|
data-home-type-filter={filter.id}
|
||||||
|
style={filter.id === 'all' ? undefined : getAccentVars(getPostTypeTheme(filter.id))}
|
||||||
|
>
|
||||||
|
<i class={`fas ${filter.icon}`}></i>
|
||||||
|
<span>{filter.name}</span>
|
||||||
|
</FilterPill>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="home-active-filters" class:list={['home-active-filter-row', !hasActiveFilters && 'hidden']}>
|
||||||
|
<span id="home-active-type" class:list={['terminal-chip max-w-full min-w-0', selectedType === 'all' && 'hidden']}>
|
||||||
|
<i id="home-active-type-icon" class={`fas ${postTypeFilters.find((item) => item.id === selectedType)?.icon || 'fa-stream'} text-[10px]`}></i>
|
||||||
|
<span id="home-active-type-text" class="min-w-0 truncate">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
id="home-active-category"
|
||||||
|
class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedCategory && 'hidden']}
|
||||||
|
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
|
||||||
|
>
|
||||||
|
<i class="fas fa-folder-open text-[10px]"></i>
|
||||||
|
<span id="home-active-category-text" class="min-w-0 truncate">{selectedCategory}</span>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
id="home-active-tag"
|
||||||
|
class:list={['terminal-chip terminal-chip--accent max-w-full min-w-0', !selectedTag && 'hidden']}
|
||||||
|
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
|
||||||
|
>
|
||||||
|
<i class="fas fa-hashtag text-[10px]"></i>
|
||||||
|
<span id="home-active-tag-text" class="min-w-0 truncate">{selectedTag}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-discovery-grid">
|
||||||
|
<section id="categories" class="home-discovery-panel">
|
||||||
|
<div class="home-discovery-panel__head">
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--title-color)]">{t('nav.categories')}</h3>
|
||||||
|
<span class="terminal-stat-pill">{categories.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="home-category-grid home-category-grid--compact">
|
||||||
|
{categories.map(category => (
|
||||||
|
<a
|
||||||
|
href={buildHomeUrl({
|
||||||
|
category: normalizedSelectedCategory === category.name.trim().toLowerCase() ? '' : category.name,
|
||||||
|
tag: selectedTag,
|
||||||
|
})}
|
||||||
|
data-home-category-filter={category.name}
|
||||||
|
class:list={[
|
||||||
|
'home-category-card terminal-panel-accent terminal-interactive-card group',
|
||||||
|
normalizedSelectedCategory === category.name.trim().toLowerCase() && 'is-active'
|
||||||
|
]}
|
||||||
|
style={getAccentVars(getCategoryTheme(category.name))}
|
||||||
|
>
|
||||||
|
<div class="home-category-card__icon terminal-accent-icon">
|
||||||
|
<i class="fas fa-folder-open"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex flex-1 items-center justify-between gap-3">
|
||||||
|
<h3 class="truncate text-sm font-semibold text-[var(--title-color)] transition-colors group-hover:text-[var(--accent-color,var(--primary))]">
|
||||||
|
{category.name}
|
||||||
|
</h3>
|
||||||
|
<span class="terminal-stat-pill terminal-stat-pill--accent shrink-0" style={getAccentVars(getCategoryTheme(category.name))}>
|
||||||
|
<span>{category.count ?? 0}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tags" class="home-discovery-panel home-tag-shell">
|
||||||
|
<div class="home-discovery-panel__head">
|
||||||
|
<h3 class="text-sm font-semibold text-[var(--title-color)]">{t('nav.tags')}</h3>
|
||||||
|
<span class="terminal-stat-pill">{tags.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="home-tag-cloud">
|
||||||
|
{tagEntries.map((tag) => (
|
||||||
|
<a
|
||||||
|
href={buildHomeUrl({
|
||||||
|
tag: normalizedSelectedTag === tag.name.trim().toLowerCase() ? '' : tag.name,
|
||||||
|
category: selectedCategory,
|
||||||
|
})}
|
||||||
|
data-home-tag-filter={tag.name}
|
||||||
|
class:list={[
|
||||||
|
'home-tag-cloud__item',
|
||||||
|
normalizedSelectedTag === tag.name.trim().toLowerCase() && 'is-active'
|
||||||
|
]}
|
||||||
|
style={getTagCloudStyle(tag.name)}
|
||||||
|
>
|
||||||
|
<span class="home-tag-cloud__hash">#</span>
|
||||||
|
<span>{tag.name}</span>
|
||||||
|
<span class="home-tag-cloud__count">{tag.count}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div id="home-posts-empty" class="terminal-empty">
|
|
||||||
<p class="text-base font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</p>
|
{pinnedPost && (
|
||||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">{t('articlesPage.emptyDescription')}</p>
|
<div>
|
||||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
<CommandPrompt command={t('home.promptPinned')} />
|
||||||
<a href="/" class="terminal-subtle-link">
|
<div id="home-pinned-wrap" class:list={['ml-4', !initialPinnedVisible && 'hidden']}>
|
||||||
<i class="fas fa-rotate-left text-[11px]"></i>
|
<div class="terminal-panel terminal-panel-accent terminal-interactive-card p-4" style={getAccentVars(getPostTypeTheme(pinnedPost.type))}>
|
||||||
<span>{t('common.clearFilters')}</span>
|
<div class="mb-2 flex items-center gap-2">
|
||||||
</a>
|
<span class="rounded bg-[var(--primary)] px-2 py-0.5 text-xs font-bold text-[var(--terminal-bg)]">{t('home.pinned')}</span>
|
||||||
<a href="/articles" class="terminal-subtle-link">
|
<span class="terminal-chip terminal-chip--accent px-2 py-1 text-[10px]" style={getAccentVars(getPostTypeTheme(pinnedPost.type))}>
|
||||||
<i class="fas fa-file-code text-[11px]"></i>
|
{pinnedPost.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||||
<span>{t('common.viewAllArticles')}</span>
|
</span>
|
||||||
</a>
|
<a href={`/articles/${pinnedPost.slug}`} class="text-lg font-bold text-[var(--title-color)] transition hover:text-[var(--primary)]">
|
||||||
|
{pinnedPost.title}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mb-2 text-sm text-[var(--text-secondary)]">{pinnedPost.date} | {t('common.readTime')}: {formatReadTime(locale, pinnedPost.readTime, t)}</p>
|
||||||
|
<p class="text-[var(--text-secondary)]">{pinnedPost.description}</p>
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href={`/articles/${pinnedPost.slug}`} class="terminal-action-button inline-flex">
|
||||||
|
<i class="fas fa-angle-right"></i>
|
||||||
|
<span>{t('common.readMore')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{allPosts.length > 0 && (
|
|
||||||
<div id="home-posts-empty" class:list={['terminal-empty', recentPosts.length > 0 && 'hidden']}>
|
<div id="posts">
|
||||||
<p class="text-base font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</p>
|
<CommandPrompt promptId="home-posts-prompt" command={postsPrompt} />
|
||||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">{t('articlesPage.emptyDescription')}</p>
|
<div class="ml-4">
|
||||||
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
{allPosts.length > 0 ? (
|
||||||
<a href="/" class="terminal-subtle-link">
|
<div id="home-posts-list" class="divide-y divide-[var(--border-color)]">
|
||||||
<i class="fas fa-rotate-left text-[11px]"></i>
|
{allPosts.map((post) => {
|
||||||
<span>{t('common.clearFilters')}</span>
|
const matchesCurrentFilter = matchesSelectedFilters(post);
|
||||||
</a>
|
const filteredIndex = matchesCurrentFilter
|
||||||
<a href="/articles" class="terminal-subtle-link">
|
? allPosts.filter(matchesSelectedFilters).findIndex((item) => item.slug === post.slug)
|
||||||
<i class="fas fa-file-code text-[11px]"></i>
|
: -1;
|
||||||
<span>{t('common.viewAllArticles')}</span>
|
const isVisible = matchesCurrentFilter && filteredIndex < previewLimit;
|
||||||
</a>
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-home-post-card
|
||||||
|
data-home-type={post.type}
|
||||||
|
data-home-category={post.category?.trim().toLowerCase() || ''}
|
||||||
|
data-home-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
|
||||||
|
data-home-pinned={post.pinned ? 'true' : 'false'}
|
||||||
|
data-home-slug={post.slug}
|
||||||
|
class:list={[!isVisible && 'hidden']}
|
||||||
|
>
|
||||||
|
<PostCard post={post} selectedTag={selectedTag} tagHrefPrefix={homeTagHrefPrefix} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div id="home-posts-empty" class="terminal-empty">
|
||||||
|
<p class="text-base font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">{t('articlesPage.emptyDescription')}</p>
|
||||||
|
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||||
|
<a href="/" class="terminal-subtle-link">
|
||||||
|
<i class="fas fa-rotate-left text-[11px]"></i>
|
||||||
|
<span>{t('common.clearFilters')}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/articles" class="terminal-subtle-link">
|
||||||
|
<i class="fas fa-file-code text-[11px]"></i>
|
||||||
|
<span>{t('common.viewAllArticles')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{allPosts.length > 0 && (
|
||||||
|
<div id="home-posts-empty" class:list={['terminal-empty', recentPosts.length > 0 && 'hidden']}>
|
||||||
|
<p class="text-base font-semibold text-[var(--title-color)]">{t('articlesPage.emptyTitle')}</p>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">{t('articlesPage.emptyDescription')}</p>
|
||||||
|
<div class="mt-4 flex flex-wrap justify-center gap-2">
|
||||||
|
<a href="/" class="terminal-subtle-link">
|
||||||
|
<i class="fas fa-rotate-left text-[11px]"></i>
|
||||||
|
<span>{t('common.clearFilters')}</span>
|
||||||
|
</a>
|
||||||
|
<a href="/articles" class="terminal-subtle-link">
|
||||||
|
<i class="fas fa-file-code text-[11px]"></i>
|
||||||
|
<span>{t('common.viewAllArticles')}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="mt-4">
|
||||||
|
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} command="cd ~/articles" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div class="mt-4">
|
|
||||||
<ViewMoreLink href="/articles" text={t('common.viewAllArticles')} command="cd ~/articles" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="popular" class="mb-10 px-4">
|
<aside class="space-y-4 xl:sticky xl:top-24 xl:self-start">
|
||||||
<CommandPrompt promptId="home-popular-prompt" command={popularPrompt} />
|
|
||||||
<div class="ml-4 grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
|
|
||||||
<section class="terminal-panel space-y-4">
|
<section class="terminal-panel space-y-4">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="space-y-1">
|
||||||
|
<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="grid gap-2 sm:grid-cols-2 xl:grid-cols-2">
|
||||||
|
{navLinks.map(link => (
|
||||||
|
<a
|
||||||
|
href={link.href}
|
||||||
|
class="group flex min-w-0 items-center gap-2 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 px-3 py-3 text-sm text-[var(--title-color)] transition hover:-translate-y-0.5 hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
||||||
|
>
|
||||||
|
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)]/10 text-[var(--primary)]">
|
||||||
|
<i class={`fas ${link.icon} text-[11px]`}></i>
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 truncate font-medium">{link.text}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<SharePanel
|
||||||
|
shareTitle={siteSettings.siteTitle}
|
||||||
|
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
|
||||||
|
canonicalUrl={siteBaseUrl}
|
||||||
|
badge={homeShareCopy.badge}
|
||||||
|
title={homeShareCopy.title}
|
||||||
|
description={homeShareCopy.description}
|
||||||
|
wechatShareQrEnabled={siteSettings.seo.wechatShareQrEnabled}
|
||||||
|
variant="compact"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="terminal-panel space-y-4">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold text-[var(--title-color)]">{t('home.hotNow')}</h3>
|
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.popularTitle}</h3>
|
||||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.popularDesc}</p>
|
||||||
{t('home.hotNowDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span>
|
<span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="home-popular-sortbar">
|
||||||
<div class="home-popular-sortbar">
|
{popularRangeOptions.map((option) => (
|
||||||
{popularRangeOptions.map((option) => (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
data-home-popular-range={option.key}
|
||||||
data-home-popular-range={option.key}
|
class:list={[
|
||||||
class:list={[
|
'home-popular-sort',
|
||||||
'home-popular-sort',
|
option.key === activeContentRange.key && 'is-active'
|
||||||
option.key === activeContentRange.key && 'is-active'
|
]}
|
||||||
]}
|
>
|
||||||
>
|
<i class="fas fa-clock text-[10px]"></i>
|
||||||
<i class="fas fa-clock text-[10px]"></i>
|
<span>{option.label}</span>
|
||||||
<span>{option.label}</span>
|
</button>
|
||||||
</button>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="home-popular-sortbar">
|
|
||||||
{popularSortOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-home-popular-sort={option.id}
|
|
||||||
class:list={[
|
|
||||||
'home-popular-sort',
|
|
||||||
option.id === 'views' && 'is-active'
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<i class={`fas ${option.icon} text-[10px]`}></i>
|
|
||||||
<span>{option.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="home-popular-list" class="space-y-3">
|
<div id="home-popular-list" class="space-y-3">
|
||||||
{popularRangeCards.map(({ rangeKey, item, post }) => {
|
{popularRangeCards.map(({ rangeKey, item, post }) => (
|
||||||
return (
|
<a
|
||||||
<a
|
href={`/articles/${post.slug}`}
|
||||||
href={`/articles/${post.slug}`}
|
data-home-popular-card
|
||||||
data-home-popular-card
|
data-home-range={rangeKey}
|
||||||
data-home-range={rangeKey}
|
data-home-type={post.type}
|
||||||
data-home-type={post.type}
|
data-home-category={post.category?.trim().toLowerCase() || ''}
|
||||||
data-home-category={post.category?.trim().toLowerCase() || ''}
|
data-home-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
|
||||||
data-home-tags={post.tags.map((tag) => tag.trim().toLowerCase()).join('|')}
|
data-home-slug={post.slug}
|
||||||
data-home-slug={post.slug}
|
data-home-popular-views={item.pageViews}
|
||||||
data-home-popular-views={item.pageViews}
|
data-home-popular-completes={item.readCompletes}
|
||||||
data-home-popular-completes={item.readCompletes}
|
data-home-popular-depth={Math.round(item.avgProgressPercent)}
|
||||||
data-home-popular-depth={Math.round(item.avgProgressPercent)}
|
class:list={[
|
||||||
class:list={[
|
'block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/72 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]',
|
||||||
'block rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/70 p-4 transition hover:border-[var(--primary)] hover:-translate-y-0.5',
|
!initialPopularVisibleKeys.has(`${rangeKey}:${post.slug}`) && 'hidden'
|
||||||
rangeKey !== activeContentRange.key && 'hidden'
|
]}
|
||||||
]}
|
style={getAccentVars(getPostTypeTheme(post.type))}
|
||||||
style={getAccentVars(getPostTypeTheme(post.type))}
|
>
|
||||||
>
|
<div class="flex items-start gap-3">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<span class="mt-0.5 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]">
|
||||||
<div class="min-w-0 flex-1">
|
{item.pageViews}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
</span>
|
||||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
|
<div class="min-w-0 flex-1">
|
||||||
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
</span>
|
<span class="terminal-chip terminal-chip--accent px-2 py-1 text-[10px]" style={getAccentVars(getPostTypeTheme(post.type))}>
|
||||||
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getCategoryTheme(post.category))}>
|
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
||||||
{post.category}
|
</span>
|
||||||
</span>
|
<span class="terminal-chip terminal-chip--accent px-2 py-1 text-[10px]" style={getAccentVars(getCategoryTheme(post.category))}>
|
||||||
</div>
|
{post.category}
|
||||||
<h4 class="mt-3 text-base font-semibold text-[var(--title-color)]">{post.title}</h4>
|
</span>
|
||||||
<p class="mt-2 line-clamp-2 text-sm leading-6 text-[var(--text-secondary)]">
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2 text-right text-xs text-[var(--text-tertiary)]">
|
<h4 class="mt-2 line-clamp-2 text-sm font-semibold leading-6 text-[var(--title-color)]">{post.title}</h4>
|
||||||
<div>{post.date}</div>
|
<div class="mt-2 flex flex-wrap gap-2 text-xs text-[var(--text-secondary)]">
|
||||||
<div>{t('common.readTime')}: {formatReadTime(locale, post.readTime, t)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex flex-wrap gap-2">
|
|
||||||
<span class="terminal-stat-pill">
|
|
||||||
<i class="fas fa-eye text-[10px] text-[var(--primary)]"></i>
|
|
||||||
<span>{t('home.views')}: {item.pageViews}</span>
|
<span>{t('home.views')}: {item.pageViews}</span>
|
||||||
</span>
|
|
||||||
<span class="terminal-stat-pill">
|
|
||||||
<i class="fas fa-check-double text-[10px] text-[var(--primary)]"></i>
|
|
||||||
<span>{t('home.completes')}: {item.readCompletes}</span>
|
<span>{t('home.completes')}: {item.readCompletes}</span>
|
||||||
</span>
|
|
||||||
<span class="terminal-stat-pill">
|
|
||||||
<i class="fas fa-chart-line text-[10px] text-[var(--primary)]"></i>
|
|
||||||
<span>{t('home.avgProgress')}: {formatProgressPercent(item.avgProgressPercent)}</span>
|
<span>{t('home.avgProgress')}: {formatProgressPercent(item.avgProgressPercent)}</span>
|
||||||
</span>
|
</div>
|
||||||
<span class="terminal-stat-pill">
|
|
||||||
<i class="fas fa-stopwatch text-[10px] text-[var(--primary)]"></i>
|
|
||||||
<span>{t('home.avgDuration')}: {formatDurationMs(item.avgDurationMs)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
);
|
</a>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="home-popular-empty" class:list={['terminal-empty', initialPopularCount > 0 && 'hidden']}>
|
<div id="home-popular-empty" class:list={['terminal-empty py-6', initialPopularCount > 0 && 'hidden']}>
|
||||||
<p class="text-sm leading-6 text-[var(--text-secondary)]">{t('home.hotNowEmpty')}</p>
|
<p class="text-sm leading-6 text-[var(--text-secondary)]">{t('home.hotNowEmpty')}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="terminal-panel space-y-4">
|
<section class="terminal-panel space-y-4">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-bold text-[var(--title-color)]">{t('home.readingSignals')}</h3>
|
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.statsTitle}</h3>
|
||||||
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
|
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.statsDesc}</p>
|
||||||
{t('home.readingSignalsDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span id="home-stats-window-pill" class="terminal-stat-pill">{activeContentRange.label}</span>
|
<span id="home-stats-window-pill" class="terminal-stat-pill">{activeContentRange.label}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-2">
|
||||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.views')}</p>
|
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.views')}</p>
|
||||||
<p id="home-reading-views-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p>
|
<p id="home-reading-views-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p>
|
||||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
|
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.completes')}</p>
|
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.completes')}</p>
|
||||||
<p id="home-reading-completes-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p>
|
<p id="home-reading-completes-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p>
|
||||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
|
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgProgress')}</p>
|
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgProgress')}</p>
|
||||||
<p id="home-reading-progress-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{formatProgressPercent(activeContentRange.overview.avgReadProgress)}</p>
|
<p id="home-reading-progress-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatProgressPercent(activeContentRange.overview.avgReadProgress)}</p>
|
||||||
<p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
|
<p id="home-reading-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg)]/70 p-4">
|
||||||
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgDuration')}</p>
|
<p class="text-xs uppercase tracking-[0.18em] text-[var(--text-tertiary)]">{t('home.avgDuration')}</p>
|
||||||
<p id="home-reading-duration-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{formatDurationMs(activeContentRange.overview.avgReadDurationMs)}</p>
|
<p id="home-reading-duration-value" class="mt-3 text-2xl font-semibold text-[var(--title-color)]">{formatDurationMs(activeContentRange.overview.avgReadDurationMs)}</p>
|
||||||
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
|
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('common.posts')}: {allPosts.length}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
{systemStats.slice(0, 4).map((item) => (
|
||||||
|
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
|
||||||
|
{sidebarFriendLinks.length > 0 && (
|
||||||
|
<section class="terminal-panel space-y-4">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--title-color)]">{homeSidebarCopy.friendsTitle}</h3>
|
||||||
|
<p class="text-sm leading-6 text-[var(--text-secondary)]">{homeSidebarCopy.friendsDesc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{sidebarFriendLinks.map((friend) => (
|
||||||
|
<a
|
||||||
|
href={friend.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="group flex items-start gap-3 rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)]/75 p-3 transition hover:-translate-y-0.5 hover:border-[var(--primary)]"
|
||||||
|
>
|
||||||
|
{friend.avatar ? (
|
||||||
|
<img
|
||||||
|
src={friend.avatar}
|
||||||
|
alt={friend.name}
|
||||||
|
class="h-10 w-10 shrink-0 rounded-xl border border-[var(--border-color)] object-cover bg-[var(--code-bg)]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-[var(--border-color)] bg-[var(--code-bg)] text-sm font-bold text-[var(--primary)]">
|
||||||
|
{friend.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="truncate text-sm font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
|
||||||
|
{friend.name}
|
||||||
|
</span>
|
||||||
|
<i class="fas fa-arrow-up-right-from-square text-[10px] text-[var(--text-tertiary)] opacity-0 transition-opacity group-hover:opacity-100"></i>
|
||||||
|
</div>
|
||||||
|
{friend.description && (
|
||||||
|
<p class="mt-1 line-clamp-2 text-xs leading-6 text-[var(--text-secondary)]">
|
||||||
|
{friend.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ViewMoreLink href="/friends" text={t('common.viewAllLinks')} command="cd ~/friends" />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-[var(--border-color)] my-8"></div>
|
<div class="my-8 border-t border-[var(--border-color)]"></div>
|
||||||
|
|
||||||
<div id="friends" class="mb-8 px-4">
|
|
||||||
<CommandPrompt command={t('home.promptFriends')} />
|
|
||||||
<div class="ml-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{friendLinks.map(friend => (
|
|
||||||
<FriendLinkCard friend={friend} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div class="mt-6 ml-4">
|
|
||||||
<ViewMoreLink href="/friends" text={t('common.viewAllLinks')} command="cd ~/friends" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-[var(--border-color)] my-8"></div>
|
|
||||||
|
|
||||||
<div id="about" class="px-4">
|
<div id="about" class="px-4">
|
||||||
<CommandPrompt command={t('home.promptAbout')} />
|
<CommandPrompt command={t('home.promptAbout')} />
|
||||||
@@ -775,6 +806,21 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="my-8 border-t border-[var(--border-color)]"></div>
|
||||||
|
|
||||||
|
<div class="px-4 pb-2">
|
||||||
|
<div class="ml-4">
|
||||||
|
<DiscoveryBrief
|
||||||
|
badge={isEnglish ? 'site brief' : homeSidebarCopy.aiBriefTitle}
|
||||||
|
kicker="geo / overview"
|
||||||
|
title={isEnglish ? 'AI-readable site brief' : '给 AI 看的站点摘要'}
|
||||||
|
summary={siteSettings.heroSubtitle || siteSettings.siteDescription}
|
||||||
|
highlights={homeBriefHighlights}
|
||||||
|
faqs={homeFaqs}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TerminalWindow>
|
</TerminalWindow>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
@@ -783,6 +829,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
|||||||
is:inline
|
is:inline
|
||||||
define:vars={{
|
define:vars={{
|
||||||
previewLimit,
|
previewLimit,
|
||||||
|
popularPreviewLimit,
|
||||||
categoryAccentMap,
|
categoryAccentMap,
|
||||||
tagAccentMap,
|
tagAccentMap,
|
||||||
contentRangesPayload: contentRanges.map((range) => ({
|
contentRangesPayload: contentRanges.map((range) => ({
|
||||||
@@ -1097,7 +1144,7 @@ const homeFaqJsonLd = buildFaqJsonLd(homeFaqs);
|
|||||||
});
|
});
|
||||||
const sortedPopular = sortPopularCards(filteredPopular);
|
const sortedPopular = sortPopularCards(filteredPopular);
|
||||||
popularCards.forEach((card) => card.classList.add('hidden'));
|
popularCards.forEach((card) => card.classList.add('hidden'));
|
||||||
sortedPopular.forEach((card) => {
|
sortedPopular.slice(0, popularPreviewLimit).forEach((card) => {
|
||||||
card.classList.remove('hidden');
|
card.classList.remove('hidden');
|
||||||
popularList?.appendChild(card);
|
popularList?.appendChild(card);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user