Files
termi-blog/frontend/src/pages/index.astro

1118 lines
49 KiB
Plaintext

---
import BaseLayout from '../layouts/BaseLayout.astro';
import TerminalWindow from '../components/ui/TerminalWindow.astro';
import CommandPrompt from '../components/ui/CommandPrompt.astro';
import FilterPill from '../components/ui/FilterPill.astro';
import PostCard from '../components/PostCard.astro';
import FriendLinkCard from '../components/FriendLinkCard.astro';
import ViewMoreLink from '../components/ui/ViewMoreLink.astro';
import StatsList from '../components/StatsList.astro';
import TechStackList from '../components/TechStackList.astro';
import SubscriptionSignup from '../components/SubscriptionSignup.astro';
import { terminalConfig } from '../lib/config/terminal';
import { api, DEFAULT_SITE_SETTINGS } from '../lib/api/client';
import { formatReadTime, getI18n } from '../lib/i18n';
import type { AppFriendLink } from '../lib/api/client';
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
export const prerender = false;
const url = new URL(Astro.request.url);
const selectedType = url.searchParams.get('type') || 'all';
const selectedTag = url.searchParams.get('tag') || '';
const selectedCategory = url.searchParams.get('category') || '';
const normalizedSelectedTag = selectedTag.trim().toLowerCase();
const normalizedSelectedCategory = selectedCategory.trim().toLowerCase();
const previewLimit = 5;
const DEFAULT_HOME_RANGE_KEY = '7d';
let siteSettings = DEFAULT_SITE_SETTINGS;
let allPosts: Post[] = [];
let recentPosts: Post[] = [];
let filteredPostsCount = 0;
let pinnedPost: Post | null = null;
let tags: string[] = [];
let friendLinks: AppFriendLink[] = [];
let categories: Awaited<ReturnType<typeof api.getCategories>> = [];
const createEmptyContentRange = (key: string, label: string, days: number): ContentWindowHighlight => ({
key,
label,
days,
overview: {
pageViews: 0,
readCompletes: 0,
avgReadProgress: 0,
avgReadDurationMs: undefined,
},
popularPosts: [],
});
let contentRanges: ContentWindowHighlight[] = [
createEmptyContentRange('24h', '24h', 1),
createEmptyContentRange('7d', '7d', 7),
createEmptyContentRange('30d', '30d', 30),
];
let contentOverview: ContentOverview = {
totalPageViews: 0,
pageViewsLast24h: 0,
pageViewsLast7d: 0,
totalReadCompletes: 0,
readCompletesLast7d: 0,
avgReadProgressLast7d: 0,
avgReadDurationMsLast7d: undefined,
};
let apiError: string | null = null;
const { locale, t } = getI18n(Astro);
const formatDurationMs = (value: number | undefined) => {
if (!value || value <= 0) return locale === 'en' ? 'N/A' : '暂无';
if (value < 1000) return `${Math.round(value)} ms`;
const seconds = value / 1000;
if (seconds < 60) {
return locale === 'en'
? `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
: `${seconds.toFixed(seconds >= 10 ? 0 : 1)} 秒`;
}
const minutes = Math.floor(seconds / 60);
const restSeconds = Math.round(seconds % 60);
return locale === 'en' ? `${minutes}m ${restSeconds}s` : `${minutes} 分 ${restSeconds} 秒`;
};
const formatProgressPercent = (value: number) => `${Math.round(value)}%`;
try {
const homeData = await api.getHomePageData();
siteSettings = homeData.siteSettings;
allPosts = homeData.posts;
tags = homeData.tags.map(tag => tag.name);
friendLinks = homeData.friendLinks.filter(friend => friend.status === 'approved');
categories = homeData.categories;
contentRanges = homeData.contentRanges;
contentOverview = homeData.contentOverview;
const filteredPosts = allPosts.filter(post => {
const normalizedCategory = post.category?.trim().toLowerCase() || '';
if (selectedType !== 'all' && post.type !== selectedType) return false;
if (selectedCategory && normalizedCategory !== normalizedSelectedCategory) return false;
if (selectedTag && !post.tags.some(tag => tag.trim().toLowerCase() === normalizedSelectedTag)) return false;
return true;
});
recentPosts = filteredPosts.slice(0, previewLimit);
filteredPostsCount = filteredPosts.length;
pinnedPost = allPosts.find(post => post.pinned) || null;
} catch (error) {
apiError = error instanceof Error ? error.message : t('common.apiUnavailable');
console.error('API Error:', error);
}
const systemStats = [
{ label: t('common.posts'), value: String(allPosts.length) },
{ label: t('common.tags'), value: String(tags.length) },
{ label: t('common.categories'), value: String(categories.length) },
{ label: t('common.friends'), value: String(friendLinks.length) },
];
const techStack = siteSettings.techStack.map(name => ({ name }));
const tagFrequency = new Map<string, number>();
for (const post of allPosts) {
for (const tag of post.tags) {
tagFrequency.set(tag, (tagFrequency.get(tag) ?? 0) + 1);
}
}
const tagEntries = tags
.map(name => ({
name,
count: tagFrequency.get(name) ?? 0,
}))
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
const getTagCloudStyle = (name: string) => {
return getAccentVars(getTagTheme(name));
};
const matchesSelectedFilters = (post: Post) => {
const normalizedCategory = post.category?.trim().toLowerCase() || '';
if (selectedType !== 'all' && post.type !== selectedType) return false;
if (selectedCategory && normalizedCategory !== normalizedSelectedCategory) return false;
if (selectedTag && !post.tags.some(tag => tag.trim().toLowerCase() === normalizedSelectedTag)) return false;
return true;
};
const activeContentRange =
contentRanges.find((range) => range.key === DEFAULT_HOME_RANGE_KEY) ??
contentRanges[0] ??
createEmptyContentRange(DEFAULT_HOME_RANGE_KEY, DEFAULT_HOME_RANGE_KEY, 7);
const popularRangeCards = contentRanges.flatMap((range) =>
range.popularPosts
.filter((item): item is PopularPostHighlight & { post: Post } => Boolean(item.post))
.map((item) => ({
rangeKey: range.key,
item,
post: item.post,
})),
);
const popularRangeOptions = contentRanges.map((range) => ({
key: range.key,
label: range.label,
}));
const initialPopularCount = popularRangeCards.filter(
({ rangeKey, post }) => rangeKey === activeContentRange.key && matchesSelectedFilters(post),
).length;
const buildHomeUrl = ({
type = selectedType,
tag = selectedTag,
category = selectedCategory,
}: {
type?: string;
tag?: string;
category?: string;
}) => {
const params = new URLSearchParams();
if (type && type !== 'all') params.set('type', type);
if (category) params.set('category', category);
if (tag) params.set('tag', tag);
const query = params.toString();
return query ? `/?${query}` : '/';
};
const homeTagHrefPrefix = `${buildHomeUrl({ tag: '' })}${buildHomeUrl({ tag: '' }).includes('?') ? '&' : '?'}tag=`;
const hasActiveFilters = selectedType !== 'all' || Boolean(selectedCategory) || Boolean(selectedTag);
const previewCount = Math.min(filteredPostsCount, previewLimit);
const categoryAccentMap = Object.fromEntries(
categories.map((category) => [category.name.trim().toLowerCase(), getAccentVars(getCategoryTheme(category.name))])
);
const tagAccentMap = Object.fromEntries(
tagEntries.map((tag) => [tag.name.trim().toLowerCase(), getAccentVars(getTagTheme(tag.name))])
);
const initialPinnedVisible = pinnedPost ? matchesSelectedFilters(pinnedPost) : false;
const postTypeFilters = [
{ id: 'all', name: t('common.all'), icon: 'fa-stream' },
{ id: 'article', name: t('common.article'), icon: 'fa-file-alt' },
{ id: 'tweet', name: t('common.tweet'), icon: 'fa-comment-dots' }
];
const activeFilterLabels = [
selectedType !== 'all' ? `type=${selectedType}` : '',
selectedCategory ? `category=${selectedCategory}` : '',
selectedTag ? `tag=${selectedTag}` : '',
].filter(Boolean);
const discoverPrompt = hasActiveFilters
? t('home.promptDiscoverFiltered', { filters: activeFilterLabels.join(' · ') })
: t('home.promptDiscoverDefault');
const postsPrompt = hasActiveFilters
? t('home.promptPostsFiltered', { count: previewCount, filters: activeFilterLabels.join(' · ') })
: t('home.promptPostsDefault', { count: previewCount });
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 = [
{ icon: 'fa-file-code', text: t('nav.articles'), href: '/articles' },
{ icon: 'fa-folder', text: t('nav.categories'), href: '/categories' },
{ icon: 'fa-tags', text: t('nav.tags'), href: '/tags' },
{ icon: 'fa-stream', text: t('nav.timeline'), href: '/timeline' },
{ icon: 'fa-star', text: t('nav.reviews'), href: '/reviews' },
{ icon: 'fa-link', text: t('nav.friends'), href: '/friends' },
{ icon: 'fa-user-secret', text: t('nav.about'), href: '/about' },
...(siteSettings.ai.enabled ? [{ icon: 'fa-robot', text: t('nav.ask'), href: '/ask' }] : []),
];
---
<BaseLayout title={siteSettings.siteTitle} description={siteSettings.siteDescription} siteSettings={siteSettings}>
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<TerminalWindow title={terminalConfig.title} class="w-full">
<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>
</div>
<div class="mb-6 px-4">
<CommandPrompt command={t('home.promptWelcome')} />
<div class="ml-4 home-hero-shell">
<div class="min-w-0">
<p class="mb-1 text-lg font-bold text-[var(--primary)]">{siteSettings.heroTitle}</p>
<p class="text-sm leading-6 text-[var(--text-secondary)]">{siteSettings.heroSubtitle}</p>
</div>
<div class="home-hero-meta">
<span class="terminal-stat-pill">
<i class="fas fa-file-alt text-[10px]"></i>
<span id="home-results-count">{t('common.resultsCount', { count: filteredPostsCount })}</span>
</span>
{siteSettings.ai.enabled && (
<a href="/ask" class="terminal-subtle-link">
<i class="fas fa-robot text-[11px]"></i>
<span>{t('nav.ask')}</span>
</a>
)}
</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>{link.text}</span>
</a>
))}
</div>
<div class="ml-4 mt-5">
<SubscriptionSignup requestUrl={Astro.request.url} />
</div>
</div>
{apiError && (
<div class="mb-8 px-4">
<div class="ml-4 p-4 rounded-lg border border-[var(--danger)]/20 bg-[var(--danger)]/10 text-[var(--danger)] text-sm">
{apiError}
</div>
</div>
)}
<div id="discover" class="mb-6 px-4">
<CommandPrompt promptId="home-discover-prompt" command={discoverPrompt} />
<div class="ml-4 terminal-panel home-discovery-shell">
<div class="home-discovery-head">
<div class="home-taxonomy-copy">
<span class="terminal-kicker">
<i class="fas fa-sliders-h"></i>
<span>home filters</span>
</span>
<div>
<h2 class="text-xl font-bold text-[var(--title-color)]">{t('common.browsePosts')}</h2>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
{t('common.categoriesCount', { count: categories.length })} · {t('common.tagsCount', { count: tags.length })} · <span id="home-discovery-results">{t('common.resultsCount', { count: filteredPostsCount })}</span>
</p>
</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', selectedType === 'all' && 'hidden']}>
<i id="home-active-type-icon" class={`fas ${postTypeFilters.find((item) => item.id === selectedType)?.icon || 'fa-stream'} text-[10px]`}></i>
<span id="home-active-type-text">{postTypeFilters.find((item) => item.id === selectedType)?.name || selectedType}</span>
</span>
<span
id="home-active-category"
class:list={['terminal-chip terminal-chip--accent', !selectedCategory && 'hidden']}
style={selectedCategory ? getAccentVars(getCategoryTheme(selectedCategory)) : undefined}
>
<i class="fas fa-folder-open text-[10px]"></i>
<span id="home-active-category-text">{selectedCategory}</span>
</span>
<span
id="home-active-tag"
class:list={['terminal-chip terminal-chip--accent', !selectedTag && 'hidden']}
style={selectedTag ? getAccentVars(getTagTheme(selectedTag)) : undefined}
>
<i class="fas fa-hashtag text-[10px]"></i>
<span id="home-active-tag-text">{selectedTag}</span>
</span>
</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 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 id="popular" class="mb-10 px-4">
<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">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-lg font-bold text-[var(--title-color)]">{t('home.hotNow')}</h3>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
{t('home.hotNowDescription')}
</p>
</div>
<span id="home-popular-count" class="terminal-stat-pill">{initialPopularCount}</span>
</div>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="home-popular-sortbar">
{popularRangeOptions.map((option) => (
<button
type="button"
data-home-popular-range={option.key}
class:list={[
'home-popular-sort',
option.key === activeContentRange.key && 'is-active'
]}
>
<i class="fas fa-clock text-[10px]"></i>
<span>{option.label}</span>
</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 id="home-popular-list" class="space-y-3">
{popularRangeCards.map(({ rangeKey, item, post }) => {
return (
<a
href={`/articles/${post.slug}`}
data-home-popular-card
data-home-range={rangeKey}
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-slug={post.slug}
data-home-popular-views={item.pageViews}
data-home-popular-completes={item.readCompletes}
data-home-popular-depth={Math.round(item.avgProgressPercent)}
class:list={[
'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',
rangeKey !== activeContentRange.key && 'hidden'
]}
style={getAccentVars(getPostTypeTheme(post.type))}
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getPostTypeTheme(post.type))}>
{post.type === 'article' ? t('common.article') : t('common.tweet')}
</span>
<span class="terminal-chip terminal-chip--accent text-[10px] py-1 px-2" style={getAccentVars(getCategoryTheme(post.category))}>
{post.category}
</span>
</div>
<h4 class="mt-3 text-base font-semibold text-[var(--title-color)]">{post.title}</h4>
<p class="mt-2 line-clamp-2 text-sm leading-6 text-[var(--text-secondary)]">
{post.description}
</p>
</div>
<div class="space-y-2 text-right text-xs text-[var(--text-tertiary)]">
<div>{post.date}</div>
<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>
<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>
<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>
<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>
</a>
);
})}
</div>
<div id="home-popular-empty" class:list={['terminal-empty', initialPopularCount > 0 && 'hidden']}>
<p class="text-sm leading-6 text-[var(--text-secondary)]">{t('home.hotNowEmpty')}</p>
</div>
</section>
<section class="terminal-panel space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 class="text-lg font-bold text-[var(--title-color)]">{t('home.readingSignals')}</h3>
<p class="mt-2 text-sm leading-6 text-[var(--text-secondary)]">
{t('home.readingSignalsDescription')}
</p>
</div>
<span id="home-stats-window-pill" class="terminal-stat-pill">{activeContentRange.label}</span>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<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 id="home-reading-views-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.pageViews}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalViews')}: {contentOverview.totalPageViews}</p>
</div>
<div class="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 id="home-reading-completes-value" class="mt-3 text-3xl font-semibold text-[var(--title-color)]">{activeContentRange.overview.readCompletes}</p>
<p class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.totalCompletes')}: {contentOverview.totalReadCompletes}</p>
</div>
<div class="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 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-window-meta" class="mt-2 text-xs text-[var(--text-secondary)]">{t('home.statsWindowLabel', { label: activeContentRange.label })}</p>
</div>
<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 id="home-reading-duration-value" class="mt-3 text-3xl 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>
</div>
</div>
</section>
</div>
</div>
<div class="border-t border-[var(--border-color)] my-8"></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">
<CommandPrompt command={t('home.promptAbout')} />
<div class="ml-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.about')}</h3>
<p class="text-[var(--text-secondary)] mb-4">{siteSettings.ownerBio}</p>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.techStack')}</h3>
<TechStackList items={techStack} />
</div>
<div>
<h3 class="text-lg font-bold mb-3 text-[var(--title-color)]">{t('home.systemStatus')}</h3>
<StatsList stats={systemStats} />
</div>
</div>
</div>
</div>
</TerminalWindow>
</div>
</BaseLayout>
<script
is:inline
define:vars={{
previewLimit,
categoryAccentMap,
tagAccentMap,
contentRangesPayload: contentRanges.map((range) => ({
key: range.key,
label: range.label,
days: range.days,
overview: range.overview,
})),
initialHomeState: {
type: selectedType,
category: selectedCategory,
tag: selectedTag,
range: activeContentRange.key,
},
}}
>
(function() {
/** @type {Window['__termiCommandPrompt']} */
let promptApi;
const t = window.__termiTranslate;
const typeButtons = Array.from(document.querySelectorAll('[data-home-type-filter]'));
const categoryButtons = Array.from(document.querySelectorAll('[data-home-category-filter]'));
const tagButtons = Array.from(document.querySelectorAll('[data-home-tag-filter]'));
const postCards = Array.from(document.querySelectorAll('[data-home-post-card]'));
const postsRoot = document.getElementById('posts');
const resultsCount = document.getElementById('home-results-count');
const discoveryResults = document.getElementById('home-discovery-results');
const clearFilters = document.getElementById('home-clear-filters');
const activeFilters = document.getElementById('home-active-filters');
const activeType = document.getElementById('home-active-type');
const activeTypeIcon = document.getElementById('home-active-type-icon');
const activeTypeText = document.getElementById('home-active-type-text');
const activeCategory = document.getElementById('home-active-category');
const activeCategoryText = document.getElementById('home-active-category-text');
const activeTag = document.getElementById('home-active-tag');
const activeTagText = document.getElementById('home-active-tag-text');
const emptyState = document.getElementById('home-posts-empty');
const pinnedWrap = document.getElementById('home-pinned-wrap');
const popularCards = Array.from(document.querySelectorAll('[data-home-popular-card]'));
const popularEmpty = document.getElementById('home-popular-empty');
const popularList = document.getElementById('home-popular-list');
const popularCount = document.getElementById('home-popular-count');
const popularRangeButtons = Array.from(document.querySelectorAll('[data-home-popular-range]'));
const popularSortButtons = Array.from(document.querySelectorAll('[data-home-popular-sort]'));
const readingWindowPill = document.getElementById('home-stats-window-pill');
const readingViewsValue = document.getElementById('home-reading-views-value');
const readingCompletesValue = document.getElementById('home-reading-completes-value');
const readingProgressValue = document.getElementById('home-reading-progress-value');
const readingDurationValue = document.getElementById('home-reading-duration-value');
const readingWindowMeta = document.getElementById('home-reading-window-meta');
promptApi = window.__termiCommandPrompt;
const contentRanges = Array.isArray(contentRangesPayload) ? contentRangesPayload : [];
const contentRangesMap = contentRanges.reduce((map, item) => {
if (item?.key) {
map[item.key] = item;
}
return map;
}, {});
const typeMeta = {
all: { icon: 'fa-stream', label: t('common.all') },
article: { icon: 'fa-file-alt', label: t('common.article') },
tweet: { icon: 'fa-comment-dots', label: t('common.tweet') },
};
const state = {
type: initialHomeState.type || 'all',
category: initialHomeState.category || '',
tag: initialHomeState.tag || '',
range: initialHomeState.range || '7d',
popularSort: 'views',
};
function getActiveRange() {
return contentRangesMap[state.range] || contentRanges[0] || {
key: '7d',
label: '7d',
days: 7,
overview: {
pageViews: 0,
readCompletes: 0,
avgReadProgress: 0,
avgReadDurationMs: undefined,
},
};
}
function syncPopularRangeButtons() {
popularRangeButtons.forEach((button) => {
button.classList.toggle(
'is-active',
(button.getAttribute('data-home-popular-range') || '7d') === state.range
);
});
}
function syncPopularSortButtons() {
popularSortButtons.forEach((button) => {
button.classList.toggle(
'is-active',
(button.getAttribute('data-home-popular-sort') || 'views') === state.popularSort
);
});
}
function sortPopularCards(cards) {
const metricKey =
state.popularSort === 'completes'
? 'data-home-popular-completes'
: state.popularSort === 'depth'
? 'data-home-popular-depth'
: 'data-home-popular-views';
return [...cards].sort((left, right) => {
const leftValue = Number(left.getAttribute(metricKey) || '0');
const rightValue = Number(right.getAttribute(metricKey) || '0');
if (rightValue !== leftValue) {
return rightValue - leftValue;
}
const leftViews = Number(left.getAttribute('data-home-popular-views') || '0');
const rightViews = Number(right.getAttribute('data-home-popular-views') || '0');
if (rightViews !== leftViews) {
return rightViews - leftViews;
}
return String(left.getAttribute('data-home-slug') || '').localeCompare(
String(right.getAttribute('data-home-slug') || '')
);
});
}
function getActiveTokens() {
return [
state.type !== 'all' ? `type=${state.type}` : '',
state.category ? `category=${state.category}` : '',
state.tag ? `tag=${state.tag}` : '',
].filter(Boolean);
}
function syncPromptText(total) {
const tokens = getActiveTokens();
const activeRange = getActiveRange();
const discoverCommand = tokens.length
? t('home.promptDiscoverFiltered', { filters: tokens.join(' · ') })
: t('home.promptDiscoverDefault');
const postsCommand = tokens.length
? t('home.promptPostsFiltered', { count: Math.min(total, previewLimit), filters: tokens.join(' · ') })
: t('home.promptPostsDefault', { count: Math.min(total, previewLimit) });
const popularCommand = t('home.promptPopularRange', { label: activeRange.label || state.range });
promptApi?.set?.('home-discover-prompt', discoverCommand, { typing: false });
promptApi?.set?.('home-posts-prompt', postsCommand, { typing: false });
promptApi?.set?.('home-popular-prompt', popularCommand, { typing: false });
}
function syncRangeMetrics(filteredPopularCount) {
const activeRange = getActiveRange();
if (popularCount) {
popularCount.textContent = String(filteredPopularCount);
}
if (readingWindowPill) {
readingWindowPill.textContent = activeRange.label || state.range;
}
if (readingViewsValue) {
readingViewsValue.textContent = String(activeRange.overview?.pageViews ?? 0);
}
if (readingCompletesValue) {
readingCompletesValue.textContent = String(activeRange.overview?.readCompletes ?? 0);
}
if (readingProgressValue) {
readingProgressValue.textContent = `${Math.round(activeRange.overview?.avgReadProgress ?? 0)}%`;
}
if (readingDurationValue) {
const durationValue = activeRange.overview?.avgReadDurationMs;
if (!durationValue || durationValue <= 0) {
readingDurationValue.textContent = document.documentElement.lang === 'en' ? 'N/A' : '暂无';
} else if (durationValue < 1000) {
readingDurationValue.textContent = `${Math.round(durationValue)} ms`;
} else {
const seconds = durationValue / 1000;
if (seconds < 60) {
readingDurationValue.textContent =
document.documentElement.lang === 'en'
? `${seconds.toFixed(seconds >= 10 ? 0 : 1)}s`
: `${seconds.toFixed(seconds >= 10 ? 0 : 1)} 秒`;
} else {
const minutes = Math.floor(seconds / 60);
const restSeconds = Math.round(seconds % 60);
readingDurationValue.textContent =
document.documentElement.lang === 'en'
? `${minutes}m ${restSeconds}s`
: `${minutes} 分 ${restSeconds} 秒`;
}
}
}
if (readingWindowMeta) {
readingWindowMeta.textContent = t('home.statsWindowLabel', {
label: activeRange.label || state.range,
});
}
}
function updateUrl() {
const params = new URLSearchParams();
if (state.type && state.type !== 'all') params.set('type', state.type);
if (state.category) params.set('category', state.category);
if (state.tag) params.set('tag', state.tag);
const nextUrl = params.toString() ? `/?${params.toString()}` : '/';
window.history.replaceState({}, '', nextUrl);
}
function syncActiveButtons() {
typeButtons.forEach((button) => {
button.classList.toggle('is-active', (button.getAttribute('data-home-type-filter') || 'all') === state.type);
});
categoryButtons.forEach((button) => {
const value = (button.getAttribute('data-home-category-filter') || '').trim().toLowerCase();
const activeValue = state.category.trim().toLowerCase();
button.classList.toggle('is-active', Boolean(activeValue) && value === activeValue);
});
tagButtons.forEach((button) => {
const value = (button.getAttribute('data-home-tag-filter') || '').trim().toLowerCase();
const activeValue = state.tag.trim().toLowerCase();
button.classList.toggle('is-active', Boolean(activeValue) && value === activeValue);
});
}
function syncActiveSummary() {
const hasActive = state.type !== 'all' || Boolean(state.category) || Boolean(state.tag);
activeFilters?.classList.toggle('hidden', !hasActive);
clearFilters?.classList.toggle('hidden', !hasActive);
if (activeType && activeTypeIcon && activeTypeText) {
const typeInfo = typeMeta[state.type] || typeMeta.all;
activeType.classList.toggle('hidden', state.type === 'all');
activeTypeIcon.className = `fas ${typeInfo.icon} text-[10px]`;
activeTypeText.textContent = typeInfo.label;
}
if (activeCategory && activeCategoryText) {
activeCategory.classList.toggle('hidden', !state.category);
activeCategoryText.textContent = state.category;
if (state.category) {
activeCategory.setAttribute('style', categoryAccentMap[String(state.category).trim().toLowerCase()] || '');
} else {
activeCategory.removeAttribute('style');
}
}
if (activeTag && activeTagText) {
activeTag.classList.toggle('hidden', !state.tag);
activeTagText.textContent = state.tag;
if (state.tag) {
activeTag.setAttribute('style', tagAccentMap[String(state.tag).trim().toLowerCase()] || '');
} else {
activeTag.removeAttribute('style');
}
}
}
function syncPostTagSelection() {
if (!postsRoot) return;
const activeValue = state.tag.trim().toLowerCase();
const tagLinks = postsRoot.querySelectorAll('a[href*="tag="]');
tagLinks.forEach((link) => {
const href = link.getAttribute('href') || '';
let tagValue = '';
try {
tagValue = new URL(href, window.location.origin).searchParams.get('tag')?.trim().toLowerCase() || '';
} catch {
tagValue = '';
}
link.classList.toggle('is-active', Boolean(activeValue) && tagValue === activeValue);
});
}
function applyHomeFilters(pushHistory = true) {
const filtered = postCards.filter((card) => {
const type = card.getAttribute('data-home-type') || '';
const category = (card.getAttribute('data-home-category') || '').trim().toLowerCase();
const tags = `|${(card.getAttribute('data-home-tags') || '').trim().toLowerCase()}|`;
const typeMatch = state.type === 'all' || type === state.type;
const categoryMatch = !state.category || category === state.category.trim().toLowerCase();
const tagMatch = !state.tag || tags.includes(`|${state.tag.trim().toLowerCase()}|`);
return typeMatch && categoryMatch && tagMatch;
});
postCards.forEach((card) => card.classList.add('hidden'));
filtered.slice(0, previewLimit).forEach((card) => card.classList.remove('hidden'));
const filteredPopular = popularCards.filter((card) => {
const range = card.getAttribute('data-home-range') || '7d';
const type = card.getAttribute('data-home-type') || '';
const category = (card.getAttribute('data-home-category') || '').trim().toLowerCase();
const tags = `|${(card.getAttribute('data-home-tags') || '').trim().toLowerCase()}|`;
const rangeMatch = range === state.range;
const typeMatch = state.type === 'all' || type === state.type;
const categoryMatch = !state.category || category === state.category.trim().toLowerCase();
const tagMatch = !state.tag || tags.includes(`|${state.tag.trim().toLowerCase()}|`);
return rangeMatch && typeMatch && categoryMatch && tagMatch;
});
const sortedPopular = sortPopularCards(filteredPopular);
popularCards.forEach((card) => card.classList.add('hidden'));
sortedPopular.forEach((card) => {
card.classList.remove('hidden');
popularList?.appendChild(card);
});
const total = filtered.length;
const hasPinned = filtered.some((card) => card.getAttribute('data-home-pinned') === 'true');
if (resultsCount) {
resultsCount.textContent = t('common.resultsCount', { count: total });
}
if (discoveryResults) {
discoveryResults.textContent = t('common.resultsCount', { count: total });
}
if (emptyState) {
emptyState.classList.toggle('hidden', total > 0);
}
if (pinnedWrap) {
pinnedWrap.classList.toggle('hidden', !hasPinned);
}
if (popularEmpty) {
popularEmpty.classList.toggle('hidden', filteredPopular.length > 0);
}
syncActiveButtons();
syncActiveSummary();
syncPostTagSelection();
syncPopularRangeButtons();
syncPopularSortButtons();
syncRangeMetrics(filteredPopular.length);
syncPromptText(total);
if (pushHistory) {
updateUrl();
}
}
typeButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
state.type = button.getAttribute('data-home-type-filter') || 'all';
applyHomeFilters();
});
});
categoryButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const nextValue = button.getAttribute('data-home-category-filter') || '';
const normalizedCurrent = state.category.trim().toLowerCase();
state.category = normalizedCurrent === nextValue.trim().toLowerCase() ? '' : nextValue;
applyHomeFilters();
});
});
tagButtons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const nextValue = button.getAttribute('data-home-tag-filter') || '';
const normalizedCurrent = state.tag.trim().toLowerCase();
state.tag = normalizedCurrent === nextValue.trim().toLowerCase() ? '' : nextValue;
applyHomeFilters();
});
});
clearFilters?.addEventListener('click', (event) => {
event.preventDefault();
state.type = 'all';
state.category = '';
state.tag = '';
applyHomeFilters();
});
popularRangeButtons.forEach((button) => {
button.addEventListener('click', () => {
state.range = button.getAttribute('data-home-popular-range') || '7d';
applyHomeFilters(false);
});
});
popularSortButtons.forEach((button) => {
button.addEventListener('click', () => {
state.popularSort = button.getAttribute('data-home-popular-sort') || 'views';
applyHomeFilters(false);
});
});
postsRoot?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('a[href*="tag="]') : null;
if (!target) return;
const href = target.getAttribute('href') || '';
try {
const nextTag = new URL(href, window.location.origin).searchParams.get('tag') || '';
if (!nextTag) return;
event.preventDefault();
const normalizedCurrent = state.tag.trim().toLowerCase();
state.tag = normalizedCurrent === nextTag.trim().toLowerCase() ? '' : nextTag;
applyHomeFilters();
} catch {
return;
}
});
applyHomeFilters(false);
})();
</script>