refactor: read page data from generated content

This commit is contained in:
SepComet 2026-05-05 09:14:51 +08:00
parent d85c8ac85d
commit 7915f8c244
6 changed files with 201 additions and 61 deletions

View File

@ -1,39 +1,35 @@
--- ---
const heatmapLevels = [ import type { GiteaActivityData } from '../data/loaders';
'#0f172a', import { site } from '../config';
'#13233e',
'#1d4ed8',
'#2563eb',
'#22c55e',
];
const heatmap = [ interface Props {
0, 1, 2, 0, 3, 1, 4, 2, 0, 1, activity: GiteaActivityData;
1, 3, 0, 2, 1, 4, 2, 1, 0, 3, }
2, 0, 1, 3, 4, 1, 0, 2, 1, 3,
0, 1, 4, 2, 0, 3, 1, 2, 4, 1,
1, 2, 0, 3, 1, 4, 2, 0, 1, 2,
0, 4, 1, 2, 3, 0, 2, 1, 4, 1,
2, 1, 3, 0, 2, 1, 4, 2, 0, 1,
];
const recentActivities = [ const { activity } = Astro.props;
{
title: 'homepage landing', const heatmapLevels = ['#0f172a', '#13233e', '#1d4ed8', '#2563eb', '#22c55e'];
detail: '重建首页骨架与模块层级', const heatmapSize = 70;
time: '今天', const heatmapCounts = activity.days.slice(-heatmapSize).map((day) => day.count);
}, const maxCount = Math.max(...heatmapCounts, 0);
{ const heatmap = Array.from({ length: heatmapSize }, (_, index) => {
title: 'design spec', const count = heatmapCounts[index] ?? 0;
detail: '完成工程编辑部风格设计文档',
time: '今天', if (count <= 0 || maxCount <= 0) {
}, return 0;
{ }
title: 'content seeds',
detail: '补齐示例日志、项目与分享数据', return Math.min(
time: '今天', heatmapLevels.length - 1,
}, Math.max(1, Math.ceil((count / maxCount) * (heatmapLevels.length - 1))),
]; );
});
const recentActivities = activity.recent.slice(0, 3);
const updatedLabel = activity.updatedAt
? new Date(activity.updatedAt).toLocaleString('zh-CN', { hour12: false })
: '尚未同步';
const isPlaceholder = activity.source === 'placeholder';
--- ---
<section class="panel activity-panel"> <section class="panel activity-panel">
@ -41,17 +37,21 @@ const recentActivities = [
<div> <div>
<span class="eyebrow">Gitea Activity</span> <span class="eyebrow">Gitea Activity</span>
<h2>活跃度概览</h2> <h2>活跃度概览</h2>
<p>首版先用静态面板承载未来的热力图与活动流位置,后续再接真实 API。</p> <p>
{isPlaceholder
? '当前展示的是同步占位结果;接入 Gitea API 后,这里会显示真实热力图与最近活动。'
: '这里展示构建阶段同步下来的 Gitea 热力图与最近活动摘要。'}
</p>
</div> </div>
<a class="section-link" href="https://gitea.sepcomet.xyz" target="_blank" rel="noreferrer"> <a class="section-link" href={site.gitea.baseUrl} target="_blank" rel="noreferrer">
打开 Gitea 打开 Gitea
</a> </a>
</div> </div>
<div class="activity-grid"> <div class="activity-grid">
<div> <div>
<p class="activity-copy">贡献热力图区域预留给未来的客户端渲染模块。当前先用静态密度块确认布局节奏与深色模块层级。</p> <p class="activity-copy">最近同步:{updatedLabel}</p>
<div class="heatmap" aria-label="示例贡献热力图"> <div class="heatmap" aria-label="Gitea 贡献热力图">
{ {
heatmap.map((level) => ( heatmap.map((level) => (
<span <span
@ -73,15 +73,22 @@ const recentActivities = [
<div class="activity-list"> <div class="activity-list">
{ {
recentActivities.map((item) => ( recentActivities.length > 0 ? (
recentActivities.map((item) => (
<article class="activity-item">
<strong class="mono">{item.repo}</strong>
<p class="activity-copy">{item.message}</p>
<p class="activity-meta" style="margin-top: 0.55rem;">
<span>{item.time}</span>
</p>
</article>
))
) : (
<article class="activity-item"> <article class="activity-item">
<strong class="mono">{item.title}</strong> <strong class="mono">暂无同步活动</strong>
<p class="activity-copy">{item.detail}</p> <p class="activity-copy">先运行一次 content:sync或等待后续接入真实 Gitea 数据源。</p>
<p class="activity-meta" style="margin-top: 0.55rem;">
<span>{item.time}</span>
</p>
</article> </article>
)) )
} }
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@ interface Props {
coverImage: string; coverImage: string;
tags: string[]; tags: string[];
repo: string; repo: string;
repoUrl?: string;
featured?: boolean; featured?: boolean;
demoVideo?: string; demoVideo?: string;
downloadLink?: string; downloadLink?: string;
@ -16,10 +17,14 @@ const {
coverImage, coverImage,
tags, tags,
repo, repo,
repoUrl,
featured = false, featured = false,
demoVideo = '', demoVideo = '',
downloadLink = '', downloadLink = '',
} = Astro.props; } = Astro.props;
const fallbackRepoUrl = repo ? `https://gitea.sepcomet.xyz/${repo}` : '';
const resolvedRepoUrl = repoUrl || fallbackRepoUrl;
--- ---
<article class="card"> <article class="card">
@ -40,9 +45,11 @@ const {
</div> </div>
<div class="project-links"> <div class="project-links">
<a class="section-link" href={`https://gitea.sepcomet.xyz/${repo}`} target="_blank" rel="noreferrer"> {resolvedRepoUrl ? (
仓库 <a class="section-link" href={resolvedRepoUrl} target="_blank" rel="noreferrer">
</a> 仓库
</a>
) : null}
{demoVideo ? ( {demoVideo ? (
<a class="section-link" href={demoVideo} target="_blank" rel="noreferrer"> <a class="section-link" href={demoVideo} target="_blank" rel="noreferrer">
Demo Demo

116
src/data/loaders.ts Normal file
View File

@ -0,0 +1,116 @@
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
type ProjectSeed = {
name: string;
description: string;
gitea_repo: string;
cover_image: string;
demo_video?: string;
download_link?: string;
tags: string[];
featured: boolean;
};
export type ProjectData = ProjectSeed & {
repo_url?: string;
updated_at?: string;
source?: 'seed' | 'gitea' | 'gitea+seafile';
};
type ShareSeed = {
name: string;
description: string;
url: string;
time: string;
};
export type ShareData = ShareSeed & {
size?: number;
source?: 'seed' | 'seafile';
updated_at?: string;
};
export type ActivityDay = {
date: string;
count: number;
};
export type RecentActivity = {
type: string;
repo: string;
message: string;
url: string;
time: string;
};
export type GiteaActivityData = {
updatedAt: string;
source: 'placeholder' | 'gitea';
itemCount: number;
days: ActivityDay[];
recent: RecentActivity[];
};
const rootDir = process.cwd();
const generatedProjectsPath = path.join(rootDir, 'src/data/generated/projects.json');
const generatedSharesPath = path.join(rootDir, 'src/data/generated/shares.json');
const generatedActivityPath = path.join(rootDir, 'src/data/generated/gitea-activity.json');
const seedProjectsPath = path.join(rootDir, 'src/content/projects/index.json');
const seedSharesPath = path.join(rootDir, 'src/content/shares/index.json');
const emptyActivity: GiteaActivityData = {
updatedAt: '',
source: 'placeholder',
itemCount: 0,
days: [],
recent: [],
};
export async function loadProjects(): Promise<ProjectData[]> {
return readJsonWithFallback<ProjectData[]>(generatedProjectsPath, seedProjectsPath);
}
export async function loadShares(): Promise<ShareData[]> {
return readJsonWithFallback<ShareData[]>(generatedSharesPath, seedSharesPath);
}
export async function loadGiteaActivity(): Promise<GiteaActivityData> {
try {
return await readJson<GiteaActivityData>(generatedActivityPath);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
return emptyActivity;
}
}
async function readJsonWithFallback<T>(preferredPath: string, fallbackPath: string): Promise<T> {
try {
return await readJson<T>(preferredPath);
} catch (error) {
if (!isMissingFileError(error)) {
throw error;
}
return readJson<T>(fallbackPath);
}
}
async function readJson<T>(filePath: string): Promise<T> {
const raw = await readFile(filePath, 'utf-8');
return JSON.parse(raw) as T;
}
function isMissingFileError(error: unknown) {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ENOENT'
);
}

View File

@ -5,17 +5,21 @@ import LogCard from '../components/LogCard.astro';
import ProjectCard from '../components/ProjectCard.astro'; import ProjectCard from '../components/ProjectCard.astro';
import RandomCornerImage from '../components/RandomCornerImage.astro'; import RandomCornerImage from '../components/RandomCornerImage.astro';
import SectionTitle from '../components/SectionTitle.astro'; import SectionTitle from '../components/SectionTitle.astro';
import Layout from '../layouts/Layout.astro';
import { site } from '../config'; import { site } from '../config';
import projects from '../content/projects/index.json'; import { loadGiteaActivity, loadProjects } from '../data/loaders';
import Layout from '../layouts/Layout.astro';
const logs = (await getCollection('logs')).sort( const [logs, projects, activity] = await Promise.all([
(a, b) => b.data.date.getTime() - a.data.date.getTime(), getCollection('logs'),
); loadProjects(),
loadGiteaActivity(),
]);
const latestLogs = logs.slice(0, 3); const sortedLogs = logs.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
const latestLogs = sortedLogs.slice(0, 3);
const featuredProjects = projects.filter((project) => project.featured).slice(0, 3); const featuredProjects = projects.filter((project) => project.featured).slice(0, 3);
const latestLog = latestLogs[0]; const latestLog = latestLogs[0];
const activeRepo = activity.recent[0]?.repo ?? featuredProjects[0]?.gitea_repo ?? 'sepcomet/personal-homepage';
--- ---
<Layout title={`${site.name} | ${site.title}`} description={site.description} currentPath="/"> <Layout title={`${site.name} | ${site.title}`} description={site.description} currentPath="/">
@ -56,7 +60,7 @@ const latestLog = latestLogs[0];
</section> </section>
<section class="status-item"> <section class="status-item">
<p class="status-item__label">Active repo</p> <p class="status-item__label">Active repo</p>
<p class="status-item__value status-item__mono">sepcomet/personal-homepage</p> <p class="status-item__value status-item__mono">{activeRepo}</p>
</section> </section>
<section class="status-item"> <section class="status-item">
<p class="status-item__label">Stack</p> <p class="status-item__label">Stack</p>
@ -106,6 +110,7 @@ const latestLog = latestLogs[0];
coverImage={project.cover_image} coverImage={project.cover_image}
tags={project.tags} tags={project.tags}
repo={project.gitea_repo} repo={project.gitea_repo}
repoUrl={project.repo_url}
featured={project.featured} featured={project.featured}
demoVideo={project.demo_video} demoVideo={project.demo_video}
downloadLink={project.download_link} downloadLink={project.download_link}
@ -118,7 +123,7 @@ const latestLog = latestLogs[0];
<section class="section"> <section class="section">
<div class="container"> <div class="container">
<GiteaActivity /> <GiteaActivity activity={activity} />
</div> </div>
</section> </section>

View File

@ -1,8 +1,10 @@
--- ---
import Layout from '../../layouts/Layout.astro';
import ProjectCard from '../../components/ProjectCard.astro'; import ProjectCard from '../../components/ProjectCard.astro';
import { site } from '../../config'; import { site } from '../../config';
import projects from '../../content/projects/index.json'; import { loadProjects } from '../../data/loaders';
import Layout from '../../layouts/Layout.astro';
const projects = await loadProjects();
--- ---
<Layout <Layout
@ -15,7 +17,7 @@ import projects from '../../content/projects/index.json';
<div class="page-intro"> <div class="page-intro">
<span class="eyebrow">Projects</span> <span class="eyebrow">Projects</span>
<h1>项目展示</h1> <h1>项目展示</h1>
<p>项目数据当前来自本地 JSON 文件,首页与此页面共享同一份结构化数据来源。</p> <p>项目页现在优先读取构建阶段生成的数据;如果尚未同步,则回退到仓库里的种子数据。</p>
</div> </div>
<div class="card-grid"> <div class="card-grid">
@ -27,6 +29,7 @@ import projects from '../../content/projects/index.json';
coverImage={project.cover_image} coverImage={project.cover_image}
tags={project.tags} tags={project.tags}
repo={project.gitea_repo} repo={project.gitea_repo}
repoUrl={project.repo_url}
featured={project.featured} featured={project.featured}
demoVideo={project.demo_video} demoVideo={project.demo_video}
downloadLink={project.download_link} downloadLink={project.download_link}

View File

@ -1,7 +1,9 @@
--- ---
import Layout from '../layouts/Layout.astro';
import { site } from '../config'; import { site } from '../config';
import shares from '../content/shares/index.json'; import { loadShares } from '../data/loaders';
import Layout from '../layouts/Layout.astro';
const shares = await loadShares();
--- ---
<Layout <Layout
@ -14,7 +16,7 @@ import shares from '../content/shares/index.json';
<div class="page-intro"> <div class="page-intro">
<span class="eyebrow">Shares</span> <span class="eyebrow">Shares</span>
<h1>分享链接</h1> <h1>分享链接</h1>
<p>这里预留给 Seafile 或其他公开资源的索引入口。当前先用静态数据确认列表版式。</p> <p>这里优先展示构建时从 Seafile 同步生成的分享数据;当前未接入时会先回退到静态种子数据。</p>
</div> </div>
<div class="card-grid" style="grid-template-columns: 1fr;"> <div class="card-grid" style="grid-template-columns: 1fr;">