feat: ship blog platform admin and deploy stack
This commit is contained in:
@@ -8,11 +8,12 @@ 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 { Post } from '../lib/types';
|
||||
import type { ContentOverview, ContentWindowHighlight, PopularPostHighlight, Post } from '../lib/types';
|
||||
import { getAccentVars, getCategoryTheme, getPostTypeTheme, getTagTheme } from '../lib/utils';
|
||||
|
||||
export const prerender = false;
|
||||
@@ -24,6 +25,7 @@ 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[] = [];
|
||||
@@ -33,9 +35,53 @@ 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();
|
||||
|
||||
@@ -44,6 +90,8 @@ try {
|
||||
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() || '';
|
||||
@@ -94,6 +142,26 @@ const matchesSelectedFilters = (post: Post) => {
|
||||
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,
|
||||
@@ -137,6 +205,12 @@ const discoverPrompt = hasActiveFilters
|
||||
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' },
|
||||
@@ -186,6 +260,10 @@ const navLinks = [
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="ml-4 mt-5">
|
||||
<SubscriptionSignup requestUrl={Astro.request.url} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
@@ -418,6 +496,160 @@ const navLinks = [
|
||||
</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">
|
||||
@@ -463,10 +695,17 @@ const navLinks = [
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
@@ -493,8 +732,27 @@ const navLinks = [
|
||||
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') },
|
||||
@@ -506,8 +764,70 @@ const navLinks = [
|
||||
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}` : '',
|
||||
@@ -518,15 +838,66 @@ const navLinks = [
|
||||
|
||||
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() {
|
||||
@@ -623,6 +994,23 @@ const navLinks = [
|
||||
|
||||
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');
|
||||
@@ -639,10 +1027,16 @@ const navLinks = [
|
||||
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) {
|
||||
@@ -686,6 +1080,20 @@ const navLinks = [
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user