feat: add project updates feed and dedicated page
Proxy and sanitize Gitea repository data via /api/project-updates Display recent commits and releases preview on the Home page Add /project-updates route for paginated commit history
This commit is contained in:
20
DESIGN.md
20
DESIGN.md
@@ -746,6 +746,24 @@ API 暴露边界:
|
|||||||
- Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。
|
- Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。
|
||||||
- 法律页面和页脚文案必须通过系统级文案 catalog 管理,并支持现有语言回退机制。
|
- 法律页面和页脚文案必须通过系统级文案 catalog 管理,并支持现有语言回退机制。
|
||||||
|
|
||||||
|
## 项目更新展示
|
||||||
|
|
||||||
|
- Home 首页可展示 Pokopia Wiki 站点项目的公开更新信息,用于让访客了解站点代码与发布进展。
|
||||||
|
- 完整项目更新页路径为 `/project-updates`,由 Home 首页项目更新预览区的 View All 入口进入。
|
||||||
|
- 更新信息来源为公开 Gitea 仓库 `https://git.tootaio.com/Kingsmai/pokopiawiki.tootaio.com`。
|
||||||
|
- 前端不得直接读取 Gitea API;后端通过 `GET /api/project-updates` 代理并净化公开仓库数据。
|
||||||
|
- 项目更新 API 只返回展示所需字段:
|
||||||
|
- 仓库:`name`、`fullName`、公开仓库 `url`、`defaultBranch`、`updatedAt`。
|
||||||
|
- 最近提交分页:`items`、`nextCursor`、`hasMore`;每条提交只包含 `sha`、`shortSha`、提交标题 `title`、完整提交消息 `message`、`createdAt`、不含邮箱的 `authorName`、公开提交 `url`。
|
||||||
|
- 发布版本:`tagName`、`name`、`publishedAt`、公开发布 `url`。
|
||||||
|
- 最近提交支持 `limit` 和不透明 `cursor` 增量读取;前端不得依赖 Gitea 的 `page` / `limit` 实现细节。
|
||||||
|
- 项目更新 API 不返回 Gitea token、用户邮箱、内部 API URL、内网地址、文件列表、提交统计、Actions 日志、构建日志或调试字段。
|
||||||
|
- Home 首页默认展示最近提交预览;用户可通过 View All 进入 `/project-updates` 完整页面。
|
||||||
|
- `/project-updates` 按 Life Post 相同的增量方式继续显示更多提交。
|
||||||
|
- `/project-updates` 的每条提交默认折叠,仅展示标题、短 SHA、作者和时间;用户可展开单条提交查看完整 Commit Message,并可再次收起。
|
||||||
|
- 若仓库后续提供 Release,可展示发布版本。没有 Release 时不展示空发布区块。
|
||||||
|
- Gitea 读取失败时不得在前台展示内部错误或调试信息。
|
||||||
|
|
||||||
## 前端交互与 UI
|
## 前端交互与 UI
|
||||||
|
|
||||||
- UI 风格以 `DesignGuidelines.html` 为准。
|
- UI 风格以 `DesignGuidelines.html` 为准。
|
||||||
@@ -792,6 +810,7 @@ API 暴露边界:
|
|||||||
- `/recipes`
|
- `/recipes`
|
||||||
- `/checklist`
|
- `/checklist`
|
||||||
- `/life`
|
- `/life`
|
||||||
|
- `/project-updates`
|
||||||
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
||||||
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
||||||
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
||||||
@@ -806,6 +825,7 @@ API 暴露边界:
|
|||||||
- `GET /api/languages`
|
- `GET /api/languages`
|
||||||
- `GET /api/system-wordings`
|
- `GET /api/system-wordings`
|
||||||
- `GET /api/options`
|
- `GET /api/options`
|
||||||
|
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
||||||
- `GET /api/daily-checklist`
|
- `GET /api/daily-checklist`
|
||||||
- `GET /api/pokemon`
|
- `GET /api/pokemon`
|
||||||
- `GET /api/pokemon/:id`
|
- `GET /api/pokemon/:id`
|
||||||
|
|||||||
@@ -212,6 +212,287 @@ function requestLocale(request: FastifyRequest): string {
|
|||||||
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectUpdatesRepository = {
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
url: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdateCommit = {
|
||||||
|
sha: string;
|
||||||
|
shortSha: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
authorName: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdateRelease = {
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectCommitPage = {
|
||||||
|
items: ProjectUpdateCommit[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdatesCursor = {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdatesResponse = {
|
||||||
|
repository: ProjectUpdatesRepository;
|
||||||
|
commits: ProjectCommitPage;
|
||||||
|
releases: ProjectUpdateRelease[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectUpdatesConfig = {
|
||||||
|
apiBaseUrl: 'https://git.tootaio.com/api/v1',
|
||||||
|
publicBaseUrl: 'https://git.tootaio.com',
|
||||||
|
owner: 'Kingsmai',
|
||||||
|
repo: 'pokopiawiki.tootaio.com',
|
||||||
|
commitLimit: 5,
|
||||||
|
maxCommitLimit: 20,
|
||||||
|
releaseLimit: 3,
|
||||||
|
timeoutMs: 5000
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function projectRepositoryPath(): string {
|
||||||
|
return `${encodeURIComponent(projectUpdatesConfig.owner)}/${encodeURIComponent(projectUpdatesConfig.repo)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectRepositoryUrl(): string {
|
||||||
|
return `${projectUpdatesConfig.publicBaseUrl}/${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectApiUrl(path = '', params: Record<string, string | number | boolean> = {}): string {
|
||||||
|
const apiBaseUrl = projectUpdatesConfig.apiBaseUrl.replace(/\/$/, '');
|
||||||
|
const url = new URL(`${apiBaseUrl}/repos/${projectRepositoryPath()}${path}`);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectField(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
|
||||||
|
if (!record) return null;
|
||||||
|
const value = record[key];
|
||||||
|
return isObjectRecord(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown> | null, key: string): string | null {
|
||||||
|
if (!record) return null;
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedDate(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectCommitTitle(message: string | null, fallback: string): string {
|
||||||
|
const [firstLine] = (message ?? '').split('\n');
|
||||||
|
return firstLine?.trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectUpdatesQueryValue(value: string | string[] | undefined): string | null {
|
||||||
|
const rawValue = Array.isArray(value) ? value[0] : value;
|
||||||
|
return rawValue?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanProjectUpdatesLimit(query: Record<string, string | string[] | undefined>): number {
|
||||||
|
const rawLimit = Number(projectUpdatesQueryValue(query.limit));
|
||||||
|
if (!Number.isInteger(rawLimit)) {
|
||||||
|
return projectUpdatesConfig.commitLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(rawLimit, 1), projectUpdatesConfig.maxCommitLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeProjectUpdatesCursor(page: number, limit: number): string {
|
||||||
|
return Buffer.from(JSON.stringify({ page, limit }), 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeProjectUpdatesCursor(cursor: string | null): ProjectUpdatesCursor | null {
|
||||||
|
if (!cursor) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||||
|
if (!isObjectRecord(payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, limit } = payload;
|
||||||
|
if (
|
||||||
|
typeof page === 'number' &&
|
||||||
|
Number.isInteger(page) &&
|
||||||
|
page > 0 &&
|
||||||
|
typeof limit === 'number' &&
|
||||||
|
Number.isInteger(limit) &&
|
||||||
|
limit > 0 &&
|
||||||
|
limit <= projectUpdatesConfig.maxCommitLimit
|
||||||
|
) {
|
||||||
|
return { page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackProjectRepository(): ProjectUpdatesRepository {
|
||||||
|
return {
|
||||||
|
name: projectUpdatesConfig.repo,
|
||||||
|
fullName: `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
|
||||||
|
url: projectRepositoryUrl(),
|
||||||
|
defaultBranch: 'main',
|
||||||
|
updatedAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectJson(path = '', params: Record<string, string | number | boolean> = {}): Promise<unknown> {
|
||||||
|
const response = await fetch(projectApiUrl(path, params), {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(projectUpdatesConfig.timeoutMs)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Project updates source failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectRepository(value: unknown): ProjectUpdatesRepository {
|
||||||
|
if (!isObjectRecord(value)) {
|
||||||
|
return fallbackProjectRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: stringField(value, 'name') ?? projectUpdatesConfig.repo,
|
||||||
|
fullName: stringField(value, 'full_name') ?? `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
|
||||||
|
url: projectRepositoryUrl(),
|
||||||
|
defaultBranch: stringField(value, 'default_branch') ?? 'main',
|
||||||
|
updatedAt: normalizedDate(stringField(value, 'updated_at'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectCommit(value: unknown): ProjectUpdateCommit | null {
|
||||||
|
if (!isObjectRecord(value)) return null;
|
||||||
|
|
||||||
|
const sha = stringField(value, 'sha');
|
||||||
|
if (!sha) return null;
|
||||||
|
|
||||||
|
const commit = objectField(value, 'commit');
|
||||||
|
const commitAuthor = objectField(commit, 'author');
|
||||||
|
const message = stringField(commit, 'message') ?? sha.slice(0, 7);
|
||||||
|
const fallback = sha.slice(0, 7);
|
||||||
|
const createdAt =
|
||||||
|
normalizedDate(stringField(value, 'created')) ??
|
||||||
|
normalizedDate(stringField(commitAuthor, 'date')) ??
|
||||||
|
normalizedDate(stringField(objectField(commit, 'committer'), 'date'));
|
||||||
|
|
||||||
|
if (!createdAt) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha,
|
||||||
|
shortSha: sha.slice(0, 7),
|
||||||
|
title: projectCommitTitle(message, fallback),
|
||||||
|
message,
|
||||||
|
createdAt,
|
||||||
|
authorName:
|
||||||
|
stringField(commitAuthor, 'name') ??
|
||||||
|
stringField(objectField(value, 'author'), 'login') ??
|
||||||
|
projectUpdatesConfig.owner,
|
||||||
|
url: `${projectRepositoryUrl()}/commit/${sha}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectRelease(value: unknown): ProjectUpdateRelease | null {
|
||||||
|
if (!isObjectRecord(value)) return null;
|
||||||
|
|
||||||
|
const tagName = stringField(value, 'tag_name');
|
||||||
|
if (!tagName) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
name: stringField(value, 'name') ?? tagName,
|
||||||
|
publishedAt: normalizedDate(stringField(value, 'published_at')) ?? normalizedDate(stringField(value, 'created_at')),
|
||||||
|
url: `${projectRepositoryUrl()}/releases/tag/${encodeURIComponent(tagName)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logProjectUpdatesError(source: string, error: unknown): void {
|
||||||
|
app.log.warn({ err: error, source }, 'Project updates source unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectCommitPage(query: Record<string, string | string[] | undefined>): Promise<ProjectCommitPage> {
|
||||||
|
const cursor = decodeProjectUpdatesCursor(projectUpdatesQueryValue(query.cursor));
|
||||||
|
const limit = cursor?.limit ?? cleanProjectUpdatesLimit(query);
|
||||||
|
const page = cursor?.page ?? 1;
|
||||||
|
const value = await fetchProjectJson('/commits', {
|
||||||
|
page,
|
||||||
|
limit: limit + 1,
|
||||||
|
stat: false,
|
||||||
|
files: false,
|
||||||
|
verification: false
|
||||||
|
});
|
||||||
|
const commits = Array.isArray(value)
|
||||||
|
? value.map(mapProjectCommit).filter((commit): commit is ProjectUpdateCommit => commit !== null)
|
||||||
|
: [];
|
||||||
|
const hasMore = commits.length > limit;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: commits.slice(0, limit),
|
||||||
|
nextCursor: hasMore ? encodeProjectUpdatesCursor(page + 1, limit) : null,
|
||||||
|
hasMore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectUpdates(query: Record<string, string | string[] | undefined> = {}): Promise<ProjectUpdatesResponse> {
|
||||||
|
const [repository, commits, releases] = await Promise.all([
|
||||||
|
fetchProjectJson()
|
||||||
|
.then(mapProjectRepository)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logProjectUpdatesError('repository', error);
|
||||||
|
return fallbackProjectRepository();
|
||||||
|
}),
|
||||||
|
getProjectCommitPage(query).catch((error: unknown) => {
|
||||||
|
logProjectUpdatesError('commits', error);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
fetchProjectJson('/releases', { limit: projectUpdatesConfig.releaseLimit, draft: false, 'pre-release': false })
|
||||||
|
.then((value) =>
|
||||||
|
Array.isArray(value) ? value.map(mapProjectRelease).filter((release): release is ProjectUpdateRelease => release !== null) : []
|
||||||
|
)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logProjectUpdatesError('releases', error);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { repository, commits, releases };
|
||||||
|
}
|
||||||
|
|
||||||
function serverMessage(
|
function serverMessage(
|
||||||
locale: string,
|
locale: string,
|
||||||
key:
|
key:
|
||||||
@@ -844,6 +1125,10 @@ app.get('/api/system-wordings', async (request) => getSystemWordings(requestLoca
|
|||||||
|
|
||||||
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||||
|
|
||||||
|
app.get('/api/project-updates', async (request) =>
|
||||||
|
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const iconCancel: AppIcon = 'mdi:close';
|
|||||||
export const iconCheck: AppIcon = 'mdi:check';
|
export const iconCheck: AppIcon = 'mdi:check';
|
||||||
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||||
|
export const iconChevronRight: AppIcon = 'mdi:chevron-right';
|
||||||
|
export const iconChevronUp: AppIcon = 'mdi:chevron-up';
|
||||||
export const iconClose: AppIcon = 'mdi:close';
|
export const iconClose: AppIcon = 'mdi:close';
|
||||||
export const iconComment: AppIcon = 'mdi:comment-outline';
|
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||||
export const iconCopy: AppIcon = 'mdi:content-copy';
|
export const iconCopy: AppIcon = 'mdi:content-copy';
|
||||||
@@ -19,6 +21,8 @@ export const iconDreamIsland: AppIcon = 'mdi:palm-tree';
|
|||||||
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
||||||
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||||
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||||
|
export const iconExternal: AppIcon = 'mdi:open-in-new';
|
||||||
|
export const iconGitCommit: AppIcon = 'mdi:source-commit';
|
||||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
export const iconHome: AppIcon = 'mdi:home-variant-outline';
|
export const iconHome: AppIcon = 'mdi:home-variant-outline';
|
||||||
export const iconImage: AppIcon = 'mdi:image-outline';
|
export const iconImage: AppIcon = 'mdi:image-outline';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import RecipeList from '../views/RecipeList.vue';
|
|||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
import LifeView from '../views/LifeView.vue';
|
import LifeView from '../views/LifeView.vue';
|
||||||
|
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
||||||
import LegalView from '../views/LegalView.vue';
|
import LegalView from '../views/LegalView.vue';
|
||||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
@@ -180,6 +181,17 @@ export const router = createRouter({
|
|||||||
},
|
},
|
||||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
||||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
||||||
|
{
|
||||||
|
path: '/project-updates',
|
||||||
|
component: ProjectUpdatesView,
|
||||||
|
meta: {
|
||||||
|
seo: seo({
|
||||||
|
titleKey: 'pages.projectUpdates.title',
|
||||||
|
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||||
|
canonicalPath: '/project-updates'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/privacy-policy',
|
path: '/privacy-policy',
|
||||||
component: LegalView,
|
component: LegalView,
|
||||||
|
|||||||
@@ -64,6 +64,48 @@ export interface UserSummary {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdatesRepository {
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
url: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdateCommit {
|
||||||
|
sha: string;
|
||||||
|
shortSha: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
authorName: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdateRelease {
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCommitPage {
|
||||||
|
items: ProjectUpdateCommit[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdates {
|
||||||
|
repository: ProjectUpdatesRepository;
|
||||||
|
commits: ProjectCommitPage;
|
||||||
|
releases: ProjectUpdateRelease[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdatesParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntityImage {
|
export interface EntityImage {
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -836,6 +878,13 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
languages: () => getJson<Language[]>('/api/languages'),
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
|
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
||||||
|
getJson<ProjectUpdates>(
|
||||||
|
`/api/project-updates${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
||||||
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
|
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
|
||||||
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
|
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
|
||||||
|
|||||||
@@ -4852,6 +4852,332 @@ button:disabled,
|
|||||||
align-self: end;
|
align-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-project-updates__panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo-label,
|
||||||
|
.home-project-updates__updated {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo a {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-weight: 950;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo a:hover {
|
||||||
|
color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__updated {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__skeleton,
|
||||||
|
.home-project-updates__content,
|
||||||
|
.home-project-updates__group,
|
||||||
|
.home-project-updates__commit {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__skeleton,
|
||||||
|
.home-project-updates__content {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__skeleton {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__group {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__group h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__list {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item {
|
||||||
|
min-height: 78px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__commit {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__title strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.28;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__sha {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__link {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--pokemon-yellow);
|
||||||
|
box-shadow: 0 3px 0 var(--line-strong);
|
||||||
|
color: #172036;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy span,
|
||||||
|
.project-updates-repo__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy a {
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-weight: 950;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy a:hover {
|
||||||
|
color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__item:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__row,
|
||||||
|
.project-updates-list__item:not(.project-updates-list__item--commit) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__title strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.28;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__sha {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-message {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-message span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-message pre {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-more-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-page {
|
.auth-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
@@ -5898,6 +6224,21 @@ button:disabled,
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-project-updates__updated {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo,
|
||||||
|
.project-updates-list__row,
|
||||||
|
.project-updates-list__item:not(.project-updates-list__item--commit) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-row__main {
|
.appearance-row__main {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -5973,6 +6314,36 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item,
|
||||||
|
.home-project-updates__title {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__title {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__link {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__title {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__actions .ui-button,
|
||||||
|
.project-updates-list__item > .ui-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.home-dex__screen {
|
.home-dex__screen {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
margin: 12px;
|
margin: 12px;
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import PokeBallMark from '../components/PokeBallMark.vue';
|
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusBadge from '../components/StatusBadge.vue';
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
import {
|
import {
|
||||||
iconAction,
|
iconAction,
|
||||||
iconAutomation,
|
iconAutomation,
|
||||||
|
iconChevronRight,
|
||||||
iconChecklist,
|
iconChecklist,
|
||||||
iconClothes,
|
iconClothes,
|
||||||
iconDish,
|
iconDish,
|
||||||
iconDreamIsland,
|
iconDreamIsland,
|
||||||
iconEvent,
|
iconEvent,
|
||||||
|
iconExternal,
|
||||||
|
iconGitCommit,
|
||||||
iconHabitat,
|
iconHabitat,
|
||||||
iconItem,
|
iconItem,
|
||||||
iconLife,
|
iconLife,
|
||||||
iconPokemon,
|
iconPokemon,
|
||||||
iconRecipe
|
iconRecipe
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
|
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const projectCommitPageSize = 5;
|
||||||
|
const projectUpdates = ref<ProjectUpdates | null>(null);
|
||||||
|
const projectUpdatesLoading = ref(true);
|
||||||
|
const projectCommits = ref<ProjectUpdateCommit[]>([]);
|
||||||
|
|
||||||
const primarySections = computed(() => [
|
const primarySections = computed(() => [
|
||||||
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
|
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
|
||||||
@@ -42,6 +51,17 @@ const futureSections = computed(() => [
|
|||||||
{ key: 'clothes', to: '/clothes', icon: iconClothes }
|
{ key: 'clothes', to: '/clothes', icon: iconClothes }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const latestReleases = computed(() => projectUpdates.value?.releases.slice(0, 3) ?? []);
|
||||||
|
const showProjectUpdates = computed(
|
||||||
|
() => projectUpdatesLoading.value || projectCommits.value.length > 0 || latestReleases.value.length > 0
|
||||||
|
);
|
||||||
|
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
||||||
|
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadProjectUpdates();
|
||||||
|
});
|
||||||
|
|
||||||
function sectionTitleKey(key: string) {
|
function sectionTitleKey(key: string) {
|
||||||
return `pages.home.sections.${key}.title`;
|
return `pages.home.sections.${key}.title`;
|
||||||
}
|
}
|
||||||
@@ -49,6 +69,34 @@ function sectionTitleKey(key: string) {
|
|||||||
function sectionDescriptionKey(key: string) {
|
function sectionDescriptionKey(key: string) {
|
||||||
return `pages.home.sections.${key}.description`;
|
return `pages.home.sections.${key}.description`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadProjectUpdates(): Promise<void> {
|
||||||
|
projectUpdatesLoading.value = true;
|
||||||
|
try {
|
||||||
|
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||||
|
projectUpdates.value = updates;
|
||||||
|
projectCommits.value = updates.commits.items;
|
||||||
|
} catch {
|
||||||
|
projectUpdates.value = null;
|
||||||
|
projectCommits.value = [];
|
||||||
|
} finally {
|
||||||
|
projectUpdatesLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,6 +193,84 @@ function sectionDescriptionKey(key: string) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showProjectUpdates" class="home-section home-project-updates" aria-labelledby="home-project-updates-title">
|
||||||
|
<div class="home-section__header">
|
||||||
|
<span class="page-kicker">{{ t('pages.home.projectUpdatesKicker') }}</span>
|
||||||
|
<h2 id="home-project-updates-title">{{ t('pages.home.projectUpdatesTitle') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-project-updates__panel">
|
||||||
|
<div v-if="projectUpdates" class="home-project-updates__repo">
|
||||||
|
<span class="home-project-updates__repo-label">{{ t('pages.home.projectUpdatesRepo') }}</span>
|
||||||
|
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconGitCommit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ projectUpdates.repository.fullName }}
|
||||||
|
</a>
|
||||||
|
<span v-if="repositoryUpdatedAt" class="home-project-updates__updated">
|
||||||
|
{{ t('pages.home.projectUpdatesUpdatedAt', { date: repositoryUpdatedAt }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectUpdatesLoading" class="home-project-updates__skeleton">
|
||||||
|
<Skeleton width="42%" />
|
||||||
|
<Skeleton width="76%" />
|
||||||
|
<Skeleton width="64%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="projectUpdates" class="home-project-updates__content">
|
||||||
|
<div v-if="latestReleases.length" class="home-project-updates__group">
|
||||||
|
<h3>{{ t('pages.home.projectUpdatesReleases') }}</h3>
|
||||||
|
<ol class="home-project-updates__list">
|
||||||
|
<li v-for="release in latestReleases" :key="release.tagName" class="home-project-updates__item">
|
||||||
|
<div class="home-project-updates__commit">
|
||||||
|
<div class="home-project-updates__title">
|
||||||
|
<span class="home-project-updates__sha">{{ release.tagName }}</span>
|
||||||
|
<strong>{{ release.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="release.publishedAt" class="home-project-updates__meta">
|
||||||
|
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="ui-button ui-button--ghost home-project-updates__link" :href="release.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.home.projectUpdatesViewRelease') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectCommits.length" class="home-project-updates__group">
|
||||||
|
<h3>{{ t('pages.home.projectUpdatesCommits') }}</h3>
|
||||||
|
<ol class="home-project-updates__list">
|
||||||
|
<li v-for="commit in projectCommits" :key="commit.sha" class="home-project-updates__item">
|
||||||
|
<div class="home-project-updates__commit">
|
||||||
|
<div class="home-project-updates__title">
|
||||||
|
<span class="home-project-updates__sha">{{ commit.shortSha }}</span>
|
||||||
|
<strong>{{ commit.title }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="home-project-updates__meta">
|
||||||
|
<span>{{ commit.authorName }}</span>
|
||||||
|
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="ui-button ui-button--ghost home-project-updates__link" :href="commit.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.home.projectUpdatesViewCommit') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div v-if="showProjectUpdatesViewAll" class="home-project-updates__actions">
|
||||||
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/project-updates">
|
||||||
|
<Icon :icon="iconChevronRight" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.home.projectUpdatesViewAll') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="home-section" aria-labelledby="home-future-title">
|
<section class="home-section" aria-labelledby="home-future-title">
|
||||||
<div class="home-section__header">
|
<div class="home-section__header">
|
||||||
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>
|
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>
|
||||||
|
|||||||
284
frontend/src/views/ProjectUpdatesView.vue
Normal file
284
frontend/src/views/ProjectUpdatesView.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import {
|
||||||
|
iconChevronDown,
|
||||||
|
iconChevronUp,
|
||||||
|
iconExternal,
|
||||||
|
iconGitCommit,
|
||||||
|
iconWarning
|
||||||
|
} from '../icons';
|
||||||
|
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const projectCommitPageSize = 10;
|
||||||
|
const projectUpdates = ref<ProjectUpdates | null>(null);
|
||||||
|
const projectCommits = ref<ProjectUpdateCommit[]>([]);
|
||||||
|
const projectCommitCursor = ref<string | null>(null);
|
||||||
|
const projectHasMoreCommits = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const loadError = ref(false);
|
||||||
|
const loadMorePaused = ref(false);
|
||||||
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||||
|
const expandedCommitShas = ref<Set<string>>(new Set());
|
||||||
|
let projectUpdatesObserver: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
const releases = computed(() => projectUpdates.value?.releases ?? []);
|
||||||
|
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadProjectUpdates();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectProjectUpdatesObserver();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadProjectUpdates(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
loadingMore.value = false;
|
||||||
|
loadError.value = false;
|
||||||
|
loadMorePaused.value = false;
|
||||||
|
projectCommitCursor.value = null;
|
||||||
|
projectHasMoreCommits.value = false;
|
||||||
|
expandedCommitShas.value = new Set();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||||
|
projectUpdates.value = updates;
|
||||||
|
projectCommits.value = updates.commits.items;
|
||||||
|
projectCommitCursor.value = updates.commits.nextCursor;
|
||||||
|
projectHasMoreCommits.value = updates.commits.hasMore;
|
||||||
|
} catch {
|
||||||
|
projectUpdates.value = null;
|
||||||
|
projectCommits.value = [];
|
||||||
|
loadError.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreProjectUpdates(): Promise<void> {
|
||||||
|
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = projectCommitCursor.value;
|
||||||
|
if (!cursor) {
|
||||||
|
projectHasMoreCommits.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingMore.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await api.projectUpdates({
|
||||||
|
cursor,
|
||||||
|
limit: projectCommitPageSize
|
||||||
|
});
|
||||||
|
projectUpdates.value = updates;
|
||||||
|
const existingShas = new Set(projectCommits.value.map((commit) => commit.sha));
|
||||||
|
projectCommits.value = [...projectCommits.value, ...updates.commits.items.filter((commit) => !existingShas.has(commit.sha))];
|
||||||
|
projectCommitCursor.value = updates.commits.nextCursor;
|
||||||
|
projectHasMoreCommits.value = updates.commits.hasMore;
|
||||||
|
} catch {
|
||||||
|
loadMorePaused.value = true;
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryLoadMore(): void {
|
||||||
|
loadMorePaused.value = false;
|
||||||
|
void loadMoreProjectUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCommitMessage(sha: string): void {
|
||||||
|
const nextExpanded = new Set(expandedCommitShas.value);
|
||||||
|
if (nextExpanded.has(sha)) {
|
||||||
|
nextExpanded.delete(sha);
|
||||||
|
} else {
|
||||||
|
nextExpanded.add(sha);
|
||||||
|
}
|
||||||
|
expandedCommitShas.value = nextExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommitExpanded(sha: string): boolean {
|
||||||
|
return expandedCommitShas.value.has(sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectProjectUpdatesObserver(): void {
|
||||||
|
projectUpdatesObserver?.disconnect();
|
||||||
|
projectUpdatesObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeProjectUpdatesLoadMore(): void {
|
||||||
|
disconnectProjectUpdatesObserver();
|
||||||
|
|
||||||
|
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value || !loadMoreSentinel.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
void loadMoreProjectUpdates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
projectUpdatesObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
void loadMoreProjectUpdates();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '360px 0px' }
|
||||||
|
);
|
||||||
|
projectUpdatesObserver.observe(loadMoreSentinel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([loadMoreSentinel, projectHasMoreCommits, loading, loadingMore, loadMorePaused], observeProjectUpdatesLoadMore, {
|
||||||
|
flush: 'post'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-stack project-updates-page">
|
||||||
|
<PageHeader :title="t('pages.projectUpdates.title')" :subtitle="t('pages.projectUpdates.subtitle')">
|
||||||
|
<template #kicker>{{ t('pages.projectUpdates.kicker') }}</template>
|
||||||
|
<template v-if="projectUpdates" #actions>
|
||||||
|
<a class="ui-button ui-button--ghost" :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.openRepository') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<section v-if="loading" class="project-updates-panel" aria-busy="true" :aria-label="t('pages.projectUpdates.loading')">
|
||||||
|
<Skeleton width="40%" height="22px" />
|
||||||
|
<Skeleton width="76%" />
|
||||||
|
<Skeleton width="64%" />
|
||||||
|
<Skeleton variant="box" height="78px" />
|
||||||
|
<Skeleton variant="box" height="78px" />
|
||||||
|
<Skeleton variant="box" height="78px" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="loadError" class="project-updates-panel">
|
||||||
|
<p class="status">{{ t('errors.loadFailed') }}</p>
|
||||||
|
<button class="ui-button ui-button--ghost" type="button" @click="loadProjectUpdates">
|
||||||
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.retry') }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-else-if="projectUpdates">
|
||||||
|
<section class="project-updates-panel">
|
||||||
|
<div class="project-updates-repo">
|
||||||
|
<span class="project-updates-repo__icon" aria-hidden="true">
|
||||||
|
<Icon :icon="iconGitCommit" class="ui-icon" />
|
||||||
|
</span>
|
||||||
|
<div class="project-updates-repo__copy">
|
||||||
|
<span>{{ t('pages.projectUpdates.sourceRepository') }}</span>
|
||||||
|
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||||
|
{{ projectUpdates.repository.fullName }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span v-if="repositoryUpdatedAt" class="project-updates-repo__meta">
|
||||||
|
{{ t('pages.projectUpdates.updatedAt', { date: repositoryUpdatedAt }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="releases.length" class="project-updates-panel">
|
||||||
|
<h2>{{ t('pages.projectUpdates.releases') }}</h2>
|
||||||
|
<ol class="project-updates-list">
|
||||||
|
<li v-for="release in releases" :key="release.tagName" class="project-updates-list__item">
|
||||||
|
<div class="project-updates-list__main">
|
||||||
|
<div class="project-updates-list__title">
|
||||||
|
<span class="project-updates-list__sha">{{ release.tagName }}</span>
|
||||||
|
<strong>{{ release.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="release.publishedAt" class="project-updates-list__meta">
|
||||||
|
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="ui-button ui-button--ghost ui-button--small" :href="release.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.viewRelease') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="project-updates-panel">
|
||||||
|
<h2>{{ t('pages.projectUpdates.commits') }}</h2>
|
||||||
|
<ol v-if="projectCommits.length" class="project-updates-list">
|
||||||
|
<li v-for="commit in projectCommits" :key="commit.sha" class="project-updates-list__item project-updates-list__item--commit">
|
||||||
|
<div class="project-updates-list__row">
|
||||||
|
<div class="project-updates-list__main">
|
||||||
|
<div class="project-updates-list__title">
|
||||||
|
<span class="project-updates-list__sha">{{ commit.shortSha }}</span>
|
||||||
|
<strong>{{ commit.title }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="project-updates-list__meta">
|
||||||
|
<span>{{ commit.authorName }}</span>
|
||||||
|
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-updates-list__actions">
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button--ghost ui-button--small"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="isCommitExpanded(commit.sha)"
|
||||||
|
@click="toggleCommitMessage(commit.sha)"
|
||||||
|
>
|
||||||
|
<Icon :icon="isCommitExpanded(commit.sha) ? iconChevronUp : iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ isCommitExpanded(commit.sha) ? t('pages.projectUpdates.collapseMessage') : t('pages.projectUpdates.expandMessage') }}
|
||||||
|
</button>
|
||||||
|
<a class="ui-button ui-button--ghost ui-button--small" :href="commit.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.viewCommit') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isCommitExpanded(commit.sha)" class="project-updates-message">
|
||||||
|
<span>{{ t('pages.projectUpdates.commitMessage') }}</span>
|
||||||
|
<pre>{{ commit.message }}</pre>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p v-else class="meta-line">{{ t('pages.projectUpdates.empty') }}</p>
|
||||||
|
|
||||||
|
<div v-if="loadingMore" class="project-updates-more-skeleton">
|
||||||
|
<Skeleton width="82%" />
|
||||||
|
<Skeleton width="58%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectHasMoreCommits" ref="loadMoreSentinel" class="project-updates-sentinel" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div v-if="loadMorePaused && projectHasMoreCommits" class="project-updates-actions">
|
||||||
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="retryLoadMore">
|
||||||
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.retry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -157,6 +157,15 @@ export const systemWordingMessages = {
|
|||||||
wikiTitle: 'Browse game records',
|
wikiTitle: 'Browse game records',
|
||||||
communityKicker: 'Daily & Community',
|
communityKicker: 'Daily & Community',
|
||||||
communityTitle: 'Follow daily tasks and community updates',
|
communityTitle: 'Follow daily tasks and community updates',
|
||||||
|
projectUpdatesKicker: 'Project Updates',
|
||||||
|
projectUpdatesTitle: 'Latest site changes',
|
||||||
|
projectUpdatesRepo: 'Source repository',
|
||||||
|
projectUpdatesUpdatedAt: 'Updated {date}',
|
||||||
|
projectUpdatesCommits: 'Recent commits',
|
||||||
|
projectUpdatesReleases: 'Releases',
|
||||||
|
projectUpdatesViewCommit: 'View commit',
|
||||||
|
projectUpdatesViewRelease: 'View release',
|
||||||
|
projectUpdatesViewAll: 'View all',
|
||||||
futureKicker: 'More Sections',
|
futureKicker: 'More Sections',
|
||||||
futureTitle: 'Planned wiki areas',
|
futureTitle: 'Planned wiki areas',
|
||||||
sections: {
|
sections: {
|
||||||
@@ -210,6 +219,24 @@ export const systemWordingMessages = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
projectUpdates: {
|
||||||
|
kicker: 'Project Updates',
|
||||||
|
title: 'Project Updates',
|
||||||
|
subtitle: 'Follow public site changes from the Pokopia Wiki source repository.',
|
||||||
|
sourceRepository: 'Source repository',
|
||||||
|
updatedAt: 'Updated {date}',
|
||||||
|
openRepository: 'Open repository',
|
||||||
|
commits: 'Commits',
|
||||||
|
releases: 'Releases',
|
||||||
|
viewCommit: 'View commit',
|
||||||
|
viewRelease: 'View release',
|
||||||
|
expandMessage: 'Expand',
|
||||||
|
collapseMessage: 'Collapse',
|
||||||
|
commitMessage: 'Commit message',
|
||||||
|
loading: 'Loading project updates',
|
||||||
|
retry: 'Retry',
|
||||||
|
empty: 'No commits yet'
|
||||||
|
},
|
||||||
legal: {
|
legal: {
|
||||||
lastUpdated: 'Last updated: May 3, 2026',
|
lastUpdated: 'Last updated: May 3, 2026',
|
||||||
sourceLinks: 'Source and reference links',
|
sourceLinks: 'Source and reference links',
|
||||||
@@ -1263,6 +1290,15 @@ export const systemWordingMessages = {
|
|||||||
wikiTitle: '浏览游戏资料',
|
wikiTitle: '浏览游戏资料',
|
||||||
communityKicker: '每日与社区',
|
communityKicker: '每日与社区',
|
||||||
communityTitle: '查看每日任务和社区更新',
|
communityTitle: '查看每日任务和社区更新',
|
||||||
|
projectUpdatesKicker: '项目更新',
|
||||||
|
projectUpdatesTitle: '最近站点改动',
|
||||||
|
projectUpdatesRepo: '源码仓库',
|
||||||
|
projectUpdatesUpdatedAt: '更新于 {date}',
|
||||||
|
projectUpdatesCommits: '最近提交',
|
||||||
|
projectUpdatesReleases: '发布版本',
|
||||||
|
projectUpdatesViewCommit: '查看提交',
|
||||||
|
projectUpdatesViewRelease: '查看发布',
|
||||||
|
projectUpdatesViewAll: '查看全部',
|
||||||
futureKicker: '更多分区',
|
futureKicker: '更多分区',
|
||||||
futureTitle: '规划中的 Wiki 区域',
|
futureTitle: '规划中的 Wiki 区域',
|
||||||
sections: {
|
sections: {
|
||||||
@@ -1316,6 +1352,24 @@ export const systemWordingMessages = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
projectUpdates: {
|
||||||
|
kicker: '项目更新',
|
||||||
|
title: '项目更新',
|
||||||
|
subtitle: '查看 Pokopia Wiki 源码仓库中的公开站点改动。',
|
||||||
|
sourceRepository: '源码仓库',
|
||||||
|
updatedAt: '更新于 {date}',
|
||||||
|
openRepository: '打开仓库',
|
||||||
|
commits: '提交记录',
|
||||||
|
releases: '发布版本',
|
||||||
|
viewCommit: '查看提交',
|
||||||
|
viewRelease: '查看发布',
|
||||||
|
expandMessage: '展开',
|
||||||
|
collapseMessage: '收起',
|
||||||
|
commitMessage: 'Commit Message',
|
||||||
|
loading: '正在加载项目更新',
|
||||||
|
retry: '重试',
|
||||||
|
empty: '暂无提交'
|
||||||
|
},
|
||||||
legal: {
|
legal: {
|
||||||
lastUpdated: '最后更新:2026年5月3日',
|
lastUpdated: '最后更新:2026年5月3日',
|
||||||
sourceLinks: '来源与参考链接',
|
sourceLinks: '来源与参考链接',
|
||||||
|
|||||||
Reference in New Issue
Block a user