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

View File

@ -5,6 +5,7 @@ interface Props {
coverImage: string;
tags: string[];
repo: string;
repoUrl?: string;
featured?: boolean;
demoVideo?: string;
downloadLink?: string;
@ -16,10 +17,14 @@ const {
coverImage,
tags,
repo,
repoUrl,
featured = false,
demoVideo = '',
downloadLink = '',
} = Astro.props;
const fallbackRepoUrl = repo ? `https://gitea.sepcomet.xyz/${repo}` : '';
const resolvedRepoUrl = repoUrl || fallbackRepoUrl;
---
<article class="card">
@ -40,9 +45,11 @@ const {
</div>
<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>
) : null}
{demoVideo ? (
<a class="section-link" href={demoVideo} target="_blank" rel="noreferrer">
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 RandomCornerImage from '../components/RandomCornerImage.astro';
import SectionTitle from '../components/SectionTitle.astro';
import Layout from '../layouts/Layout.astro';
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(
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
);
const [logs, projects, activity] = await Promise.all([
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 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="/">
@ -56,7 +60,7 @@ const latestLog = latestLogs[0];
</section>
<section class="status-item">
<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 class="status-item">
<p class="status-item__label">Stack</p>
@ -106,6 +110,7 @@ const latestLog = latestLogs[0];
coverImage={project.cover_image}
tags={project.tags}
repo={project.gitea_repo}
repoUrl={project.repo_url}
featured={project.featured}
demoVideo={project.demo_video}
downloadLink={project.download_link}
@ -118,7 +123,7 @@ const latestLog = latestLogs[0];
<section class="section">
<div class="container">
<GiteaActivity />
<GiteaActivity activity={activity} />
</div>
</section>

View File

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

View File

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