feat: 更新样式和功能,优化徽章、登录页面和文章页面的布局,增强可访问性和用户体验

This commit is contained in:
2026-04-03 04:10:35 +08:00
parent 36d505ece6
commit 83f3c8d249
8 changed files with 353 additions and 153 deletions

View File

@@ -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',
}, },

View File

@@ -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;
} }

View File

@@ -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>
) )
} }

View File

@@ -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>

View File

@@ -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');
} }
}); });
} }

View File

@@ -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() {

View File

@@ -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;
} }

View File

@@ -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;