Compare commits

..

3 Commits

Author SHA1 Message Date
f2a8b67ebf feat(admin): add data tools for export, import, and wipe
Add admin.data.export and admin.data.import permissions
Implement backend logic and API endpoints for data bundle management
Add Data Tools tab to admin interface with scope selection
Support Pokemon, Habitats, Items, Recipes, and Daily CheckList scopes
2026-05-04 00:56:37 +08:00
fa06d24826 feat(pokemon): store official data identity separate from display ID
Add data_id and data_identifier to pokemon schema
Use official data ID as internal route ID for non-event pokemon
Prevent applying fetched data with mismatched ID to existing pokemon
2026-05-04 00:06:22 +08:00
8dfd03f3d2 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
2026-05-03 23:40:34 +08:00
13 changed files with 2251 additions and 50 deletions

View File

@@ -184,6 +184,42 @@
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
- 管理 API 只返回权限管理所需字段不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。
## Admin Data Tools
- Admin Data Tools 用于在管理端导出、导入和清空指定 Wiki 内容域数据。
- Data Tools 只支持固定业务范围,不提供任意 SQL、任意表名输入或网页数据库控制台能力。
- 权限:
- `admin.data.export`:可导出内容数据 bundle。
- `admin.data.import`:可导入内容数据 bundle并可执行 Wipe。
- 初始默认只有 `owner` 拥有 Data Tools 权限;如需开放给其他角色,必须通过权限管理显式授予。
- Data Tools 支持范围:
- Pokemon
- Habitats
- Items
- Recipes
- Daily CheckList
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes前端确认内容也必须显示 Recipes。
- Wipe 行为:
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
- Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
- 对被清空的 identity 主表重置自增 IDPokemon 内部 ID 不是 identity自定义 / 活动 Pokemon 的系统分配区间仍按当前数据库最大值继续。
- Export 行为:
- 导出为版本化 JSON bundle包含 `version``exportedAt``scopes` 和对应范围数据。
- JSON bundle 用于系统导入,不作为前台展示内容。
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
- Import 行为:
- 当前只支持 Replace selected scopes导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。
- Import 不自动覆盖系统配置、语言、用户、角色、权限、系统文案或 Life 内容。
- 导入数据引用的 System config、Languages、Users 或上传文件路径必须已存在;缺失依赖会导致导入失败并回滚。
- Import 完成后重置相关 identity sequence 到当前最大 ID 之后。
- 前端导入和 Wipe 必须使用确认 Modal并要求输入固定确认词后才能执行。
## Referral
- Referral 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。
@@ -407,7 +443,8 @@
Pokemon 可配置:
- 内部 ID`id`,系统唯一,用于路由、外键和实体关联;普通 Pokemon 新建时优先与展示 ID 一致,活动 Pokemon 由系统分配唯一内部 ID
- 内部 ID`id`,系统唯一,用于路由、外键和实体关联;从 CSV Fetch 创建的普通 Pokemon 使用官方 data Pokemon ID 作为内部 ID活动 Pokemon 和未关联官方 data 的自定义 Pokemon 由系统分配唯一内部 ID
- 官方 data 身份:`data_id``data_identifier`,可为空;用于记录该 Pokemon 对应的 CSV 官方 Pokemon ID 与 identifier不作为用户可编辑展示 ID
- 展示 ID`display_id`,详情页、列表卡片和选择器中显示为 `#ID`
- 是否为活动物品:`is_event_item`
- 名称
@@ -432,7 +469,7 @@ Pokemon 可配置:
- 翻译
- 排序
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。
Pokemon 编辑表单使用标签页组织字段:
@@ -442,8 +479,9 @@ Pokemon 编辑表单使用标签页组织字段:
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称结果只展示 `#ID`、名称和 identifier。
- Fetch 搜索结果默认关闭只在用户主动点击输入框或输入内容时展开Escape、失焦 / 点击外部、选择结果后关闭。
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
- Fetch 只填入 CSV 可提供的字段:官方 data ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
- Fetch 只填入 CSV 可提供的字段:官方 data ID、官方 data identifier、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
- Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。
- Fetch 后保存普通 Pokemon 时,官方 data ID 作为内部路由 ID展示 ID 只保存到 `display_id`
- Fetch 不直接创建或更新 Pokemon用户仍需通过 Save 保存,保存时沿用现有编辑审计。
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en``ja``ko``fr``de``es``it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans``zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`Type ID 与 `data/localized_type_name.csv``frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
@@ -746,6 +784,24 @@ API 暴露边界:
- Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。
- 法律页面和页脚文案必须通过系统级文案 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 风格以 `DesignGuidelines.html` 为准。
@@ -753,7 +809,7 @@ API 暴露边界:
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
- 配置System config。
- 内容Daily CheckList、Pokemon、物品、材料单栖息地的维护、排序或删除入口。
- 内容Daily CheckList、Pokemon、物品、材料单栖息地的维护、排序或删除入口,以及 Data Tools
- 本地化Languages、System wordings。
- 访问权限Users、Roles、Permissions、Rate limits。
- 登录用户的侧边栏账号入口进入 `/profile`User Profile 属于账号入口,不作为 Wiki 主内容导航项。
@@ -792,6 +848,7 @@ API 暴露边界:
- `/recipes`
- `/checklist`
- `/life`
- `/project-updates`
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
@@ -806,6 +863,7 @@ API 暴露边界:
- `GET /api/languages`
- `GET /api/system-wordings`
- `GET /api/options`
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
- `GET /api/daily-checklist`
- `GET /api/pokemon`
- `GET /api/pokemon/:id`
@@ -848,6 +906,10 @@ API 暴露边界:
- `POST /api/admin/permissions`:需要 `admin.permissions.create`
- `PUT /api/admin/permissions/:id`:需要 `admin.permissions.update`
- `DELETE /api/admin/permissions/:id`:需要 `admin.permissions.delete`
- `GET /api/admin/data-tools/summary`:需要 `admin.data.export``admin.data.import`
- `POST /api/admin/data-tools/export`:需要 `admin.data.export`
- `POST /api/admin/data-tools/import`:需要 `admin.data.import`
- `POST /api/admin/data-tools/wipe`:需要 `admin.data.import`
受权限保护的编辑 API

View File

@@ -219,6 +219,8 @@ VALUES
('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
('admin.config.delete', 'Delete system config', 'Delete management configuration records.', 'System config', true),
('admin.config.order', 'Order system config', 'Reorder management configuration records.', 'System config', true),
('admin.data.export', 'Export data', 'Export content data bundles.', 'Data tools', true),
('admin.data.import', 'Import and wipe data', 'Import content data bundles and wipe content data.', 'Data tools', true),
('checklist.create', 'Create checklist tasks', 'Create Daily CheckList tasks.', 'CheckList', true),
('checklist.update', 'Update checklist tasks', 'Edit Daily CheckList tasks.', 'CheckList', true),
('checklist.delete', 'Delete checklist tasks', 'Delete Daily CheckList tasks.', 'CheckList', true),
@@ -712,6 +714,8 @@ CREATE TABLE IF NOT EXISTS pokemon_types (
CREATE TABLE IF NOT EXISTS pokemon (
id integer PRIMARY KEY,
data_id integer CHECK (data_id > 0),
data_identifier text NOT NULL DEFAULT '',
display_id integer NOT NULL CHECK (display_id > 0),
name text NOT NULL UNIQUE,
is_event_item boolean NOT NULL DEFAULT false,
@@ -738,6 +742,10 @@ CREATE TABLE IF NOT EXISTS pokemon (
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE pokemon
ADD COLUMN IF NOT EXISTS data_id integer CHECK (data_id > 0),
ADD COLUMN IF NOT EXISTS data_identifier text NOT NULL DEFAULT '';
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,

View File

@@ -22,6 +22,19 @@ type QueryValue = string | string[] | undefined;
type QueryParams = Record<string, QueryValue>;
type DbClient = PoolClient;
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
type DataToolScopeSummary = {
scope: DataToolScope;
count: number;
};
type DataToolRows = Record<string, unknown>[];
type DataToolScopeData = Record<string, DataToolRows | undefined>;
type DataToolsBundle = {
version: 1;
exportedAt: string;
scopes: DataToolScope[];
data: Partial<Record<DataToolScope, DataToolScopeData>>;
};
type TranslationField = 'name' | 'title' | 'details' | 'genus';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
@@ -110,6 +123,8 @@ type PokemonImageOptionsResult = {
};
type PokemonPayload = {
dataId: number | null;
dataIdentifier: string;
displayId: number;
isEventItem: boolean;
name: string;
@@ -897,28 +912,19 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise<numbe
return result.rows[0]?.sortOrder ?? 10;
}
async function nextPokemonInternalId(client: DbClient, displayId: number, isEventItem: boolean): Promise<number> {
if (isEventItem) {
async function nextPokemonInternalId(
client: DbClient,
dataId: number | null,
isEventItem: boolean
): Promise<number> {
if (!isEventItem && dataId !== null) {
return dataId;
}
const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
);
const nextId = result.rows[0]?.id ?? 1000000;
return nextId === displayId ? nextId + 1 : nextId;
}
if (!isEventItem) {
const preferredId = await client.query<{ id: number }>('SELECT id FROM pokemon WHERE id = $1', [displayId]);
if (preferredId.rowCount === 0) {
return displayId;
}
}
const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 0) + 1 AS id FROM pokemon WHERE id <> $1',
[displayId]
);
const nextId = result.rows[0]?.id ?? 1;
return nextId === displayId ? nextId + 1 : nextId;
return result.rows[0]?.id ?? 1000000;
}
async function reorderTableRows(
@@ -2160,6 +2166,8 @@ function pokemonProjection(locale: string): string {
return `
SELECT
p.id,
p.data_id AS "dataId",
p.data_identifier AS "dataIdentifier",
p.display_id AS "displayId",
${pokemonName} AS name,
p.name AS "baseName",
@@ -4684,6 +4692,8 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
return {
dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
dataIdentifier: cleanOptionalText(payload.dataIdentifier),
displayId,
isEventItem: Boolean(payload.isEventItem),
name: cleanName(payload.name, 'server.validation.pokemonNameRequired'),
@@ -4702,6 +4712,21 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
};
}
async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise<void> {
if (payload.dataId === null) {
payload.dataIdentifier = '';
return;
}
const data = await loadPokemonCsvData();
const pokemonRow = data.pokemonByLookup.get(String(payload.dataId));
if (!pokemonRow) {
throw validationError('server.validation.pokemonDataNotFound');
}
payload.dataIdentifier = csvText(pokemonRow, 'identifier');
}
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
@@ -4749,14 +4774,17 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanPokemonPayload(payload);
await normalizePokemonDataIdentity(cleanPayload);
const id = await withTransaction(async (client) => {
const pokemonId = await nextPokemonInternalId(client, cleanPayload.displayId, cleanPayload.isEventItem);
const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId, cleanPayload.isEventItem);
const sortOrder = await nextSortOrder(client, 'pokemon');
await client.query(
`
INSERT INTO pokemon (
id,
data_id,
data_identifier,
display_id,
name,
is_event_item,
@@ -4780,10 +4808,12 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $22)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $24)
`,
[
pokemonId,
cleanPayload.dataId,
cleanPayload.dataIdentifier,
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.isEventItem,
@@ -4818,6 +4848,10 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanPokemonPayload(payload);
await normalizePokemonDataIdentity(cleanPayload);
if (!cleanPayload.isEventItem && cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
throw validationError('server.validation.pokemonDataIdMismatch');
}
const before = await getPokemon(id, defaultLocale);
const updated = await withTransaction(async (client) => {
@@ -4825,30 +4859,34 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
`
UPDATE pokemon
SET
display_id = $1,
name = $2,
is_event_item = $3,
genus = $4,
details = $5,
height_inches = $6,
weight_pounds = $7,
environment_id = $8,
hp = $9,
attack = $10,
defense = $11,
special_attack = $12,
special_defense = $13,
speed = $14,
image_path = $15,
image_style = $16,
image_version = $17,
image_variant = $18,
image_description = $19,
updated_by_user_id = $20,
data_id = $1,
data_identifier = $2,
display_id = $3,
name = $4,
is_event_item = $5,
genus = $6,
details = $7,
height_inches = $8,
weight_pounds = $9,
environment_id = $10,
hp = $11,
attack = $12,
defense = $13,
special_attack = $14,
special_defense = $15,
speed = $16,
image_path = $17,
image_style = $18,
image_version = $19,
image_variant = $20,
image_description = $21,
updated_by_user_id = $22,
updated_at = now()
WHERE id = $21
WHERE id = $23
`,
[
cleanPayload.dataId,
cleanPayload.dataIdentifier,
cleanPayload.displayId,
cleanPayload.name,
cleanPayload.isEventItem,
@@ -5736,3 +5774,444 @@ export async function deleteRecipe(id: number, userId: number) {
return true;
});
}
const dataToolScopes = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolMainTables: Record<DataToolScope, string> = {
pokemon: 'pokemon',
habitats: 'habitats',
items: 'items',
recipes: 'recipes',
checklist: 'daily_checklist_items'
};
const dataToolColumns = {
pokemon: [
'id',
'data_id',
'data_identifier',
'display_id',
'name',
'is_event_item',
'genus',
'details',
'height_inches',
'weight_pounds',
'environment_id',
'hp',
'attack',
'defense',
'special_attack',
'special_defense',
'speed',
'image_path',
'image_style',
'image_version',
'image_variant',
'image_description',
'sort_order',
'created_by_user_id',
'updated_by_user_id',
'created_at',
'updated_at'
],
pokemonTypeLinks: ['pokemon_id', 'type_id', 'slot_order'],
pokemonSkills: ['pokemon_id', 'skill_id'],
pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'],
pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'],
habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'],
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
items: [
'id',
'name',
'category_id',
'usage_id',
'dyeable',
'dual_dyeable',
'pattern_editable',
'no_recipe',
'is_event_item',
'image_path',
'sort_order',
'created_by_user_id',
'updated_by_user_id',
'created_at',
'updated_at'
],
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
checklist: ['id', 'title', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
translations: ['entity_type', 'entity_id', 'locale', 'field_name', 'value'],
editLogs: ['id', 'entity_type', 'entity_id', 'action', 'user_id', 'changes', 'created_at'],
imageUploads: [
'id',
'entity_type',
'entity_id',
'entity_name',
'path',
'original_filename',
'mime_type',
'byte_size',
'created_by_user_id',
'created_at'
],
discussionComments: [
'id',
'entity_type',
'entity_id',
'parent_comment_id',
'body',
'ai_moderation_status',
'ai_moderation_language_code',
'ai_moderation_content_hash',
'ai_moderation_checked_at',
'ai_moderation_retry_count',
'ai_moderation_updated_at',
'created_by_user_id',
'deleted_by_user_id',
'deleted_at',
'created_at',
'updated_at'
]
} as const;
function isDataToolScope(value: unknown): value is DataToolScope {
return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
}
function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] {
const scopeSet = new Set(scopes);
if (scopeSet.has('items')) {
scopeSet.add('recipes');
}
return dataToolScopes.filter((scope) => scopeSet.has(scope));
}
function cleanDataToolScopes(value: unknown): DataToolScope[] {
if (!Array.isArray(value)) {
throw validationError('server.validation.dataToolScopeRequired');
}
const scopes: DataToolScope[] = [];
for (const scope of value) {
if (!isDataToolScope(scope)) {
throw validationError('server.validation.dataToolScopeInvalid');
}
if (!scopes.includes(scope)) {
scopes.push(scope);
}
}
if (scopes.length === 0) {
throw validationError('server.validation.dataToolScopeRequired');
}
return normalizeDataToolScopes(scopes);
}
function cleanDataToolsBundle(value: unknown): DataToolsBundle {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw validationError('server.validation.dataToolBundleInvalid');
}
const bundle = value as Record<string, unknown>;
if (bundle.version !== 1 || !bundle.data || typeof bundle.data !== 'object' || Array.isArray(bundle.data)) {
throw validationError('server.validation.dataToolBundleInvalid');
}
return {
version: 1,
exportedAt: typeof bundle.exportedAt === 'string' ? bundle.exportedAt : new Date().toISOString(),
scopes: cleanDataToolScopes(bundle.scopes),
data: bundle.data as DataToolsBundle['data']
};
}
function dataToolTableRows(data: DataToolScopeData | undefined, key: string): DataToolRows {
const rows = data?.[key];
if (rows === undefined) {
return [];
}
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) {
throw validationError('server.validation.dataToolBundleInvalid');
}
return rows as DataToolRows;
}
function dataToolDataWithRows(key: string, ...sources: Array<DataToolScopeData | undefined>): DataToolScopeData | undefined {
return sources.find((source) => source?.[key] !== undefined);
}
async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise<DataToolRows> {
const result = await client.query<Record<string, unknown>>(sql, params);
return result.rows;
}
function normalizeImportValue(column: string, value: unknown): unknown {
if (value === undefined) {
return null;
}
if (column === 'changes' && typeof value !== 'string') {
return JSON.stringify(value ?? []);
}
return value;
}
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
for (const row of rows) {
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
const values = columns.map((column) => normalizeImportValue(column, row[column]));
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
}
}
async function resetIdentity(client: DbClient, tableName: string): Promise<void> {
const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`);
const maxId = result.rows[0]?.maxId ?? null;
if (maxId === null) {
await client.query(`ALTER TABLE ${tableName} ALTER COLUMN id RESTART WITH 1`);
return;
}
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3, true)', [tableName, 'id', maxId]);
}
async function resetDataToolIdentities(client: DbClient): Promise<void> {
for (const tableName of ['daily_checklist_items', 'items', 'recipes', 'habitats', 'wiki_edit_logs', 'entity_image_uploads', 'entity_discussion_comments']) {
await resetIdentity(client, tableName);
}
}
async function deleteGenericEntityRows(client: DbClient, entityTypes: string[]): Promise<void> {
await client.query('DELETE FROM entity_discussion_comments WHERE entity_type = ANY($1::text[])', [entityTypes]);
await client.query('DELETE FROM entity_image_uploads WHERE entity_type = ANY($1::text[])', [entityTypes]);
await client.query('DELETE FROM wiki_edit_logs WHERE entity_type = ANY($1::text[])', [entityTypes]);
await client.query('DELETE FROM entity_translations WHERE entity_type = ANY($1::text[])', [entityTypes]);
}
async function wipeRecipesData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['recipes']);
await client.query('DELETE FROM recipe_acquisition_methods');
await client.query('DELETE FROM recipe_materials');
await client.query('DELETE FROM recipes');
}
async function wipeItemsData(client: DbClient): Promise<void> {
await wipeRecipesData(client);
await deleteGenericEntityRows(client, ['items']);
await client.query('DELETE FROM item_acquisition_methods');
await client.query('DELETE FROM item_favorite_things');
await client.query('DELETE FROM habitat_recipe_items');
await client.query('DELETE FROM pokemon_skill_item_drops');
await client.query('DELETE FROM items');
}
async function wipePokemonData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['pokemon']);
await client.query('DELETE FROM habitat_pokemon');
await client.query('DELETE FROM pokemon_skill_item_drops');
await client.query('DELETE FROM pokemon_pokemon_types');
await client.query('DELETE FROM pokemon_skills');
await client.query('DELETE FROM pokemon_favorite_things');
await client.query('DELETE FROM pokemon');
}
async function wipeHabitatsData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['habitats']);
await client.query('DELETE FROM habitat_recipe_items');
await client.query('DELETE FROM habitat_pokemon');
await client.query('DELETE FROM habitats');
}
async function wipeChecklistData(client: DbClient): Promise<void> {
await deleteGenericEntityRows(client, ['daily-checklist-items']);
await client.query('DELETE FROM daily_checklist_items');
}
async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], resetIdentities = true): Promise<void> {
const scopeSet = new Set(scopes);
if (scopeSet.has('items')) {
await wipeItemsData(client);
} else if (scopeSet.has('recipes')) {
await wipeRecipesData(client);
}
if (scopeSet.has('pokemon')) {
await wipePokemonData(client);
}
if (scopeSet.has('habitats')) {
await wipeHabitatsData(client);
}
if (scopeSet.has('checklist')) {
await wipeChecklistData(client);
}
if (resetIdentities) {
await resetDataToolIdentities(client);
}
}
async function exportGenericScopeData(client: DbClient, entityType: string, includeImages: boolean): Promise<DataToolScopeData> {
const data: DataToolScopeData = {
translations: await tableRows(client, 'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name', [entityType]),
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', [entityType]),
discussionComments: await tableRows(
client,
`
SELECT *
FROM entity_discussion_comments
WHERE entity_type = $1
ORDER BY parent_comment_id NULLS FIRST, id
`,
[entityType]
)
};
if (includeImages) {
data.imageUploads = await tableRows(client, 'SELECT * FROM entity_image_uploads WHERE entity_type = $1 ORDER BY id', [entityType]);
}
return data;
}
async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
if (scope === 'pokemon') {
return {
pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY sort_order, id'),
pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'),
pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
...(await exportGenericScopeData(client, 'pokemon', true))
};
}
if (scope === 'habitats') {
return {
habitats: await tableRows(client, 'SELECT * FROM habitats ORDER BY sort_order, id'),
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
...(await exportGenericScopeData(client, 'habitats', true))
};
}
if (scope === 'items') {
return {
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
...(await exportGenericScopeData(client, 'items', true))
};
}
if (scope === 'recipes') {
return {
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
recipeAcquisitionMethods: await tableRows(client, 'SELECT * FROM recipe_acquisition_methods ORDER BY recipe_id, acquisition_method_id'),
recipeMaterials: await tableRows(client, 'SELECT * FROM recipe_materials ORDER BY recipe_id, item_id'),
...(await exportGenericScopeData(client, 'recipes', false))
};
}
return {
checklist: await tableRows(client, 'SELECT * FROM daily_checklist_items ORDER BY sort_order, id'),
translations: await tableRows(
client,
'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name',
['daily-checklist-items']
),
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', ['daily-checklist-items'])
};
}
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
const itemData = bundle.data.items;
const pokemonData = bundle.data.pokemon;
const habitatData = bundle.data.habitats;
const checklistData = bundle.data.checklist;
const recipeData = bundle.data.recipes;
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
await insertRows(client, 'recipes', dataToolColumns.recipes, dataToolTableRows(recipeData, 'recipes'));
}
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
const itemData = bundle.data.items;
const pokemonData = bundle.data.pokemon;
const habitatData = bundle.data.habitats;
const recipeData = bundle.data.recipes;
const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData);
const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData);
const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData);
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops'));
await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods'));
await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials'));
await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems'));
await insertRows(client, 'habitat_pokemon', dataToolColumns.habitatPokemon, dataToolTableRows(habitatPokemonData, 'habitatPokemon'));
}
async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
for (const scope of bundle.scopes) {
const data = bundle.data[scope];
await insertRows(client, 'entity_translations', dataToolColumns.translations, dataToolTableRows(data, 'translations'));
await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs'));
await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
}
}
async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle): Promise<void> {
await importScopeMainRows(client, bundle);
await importScopeRelationRows(client, bundle);
await importGenericScopeRows(client, bundle);
await resetDataToolIdentities(client);
}
export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> {
const scopes: DataToolScopeSummary[] = [];
for (const scope of dataToolScopes) {
const result = await queryOne<{ count: number }>(`SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`);
scopes.push({ scope, count: result?.count ?? 0 });
}
return { scopes };
}
export async function exportAdminData(payload: Record<string, unknown>): Promise<DataToolsBundle> {
const scopes = cleanDataToolScopes(payload.scopes);
return withTransaction(async (client) => {
const data: DataToolsBundle['data'] = {};
for (const scope of scopes) {
data[scope] = await exportScopeData(client, scope);
}
return { version: 1, exportedAt: new Date().toISOString(), scopes, data };
});
}
export async function importAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
const bundle = cleanDataToolsBundle(payload.bundle);
await withTransaction(async (client) => {
await wipeDataToolScopes(client, bundle.scopes, false);
await importDataToolsBundle(client, bundle);
});
return getAdminDataToolsSummary();
}
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
const scopes = cleanDataToolScopes(payload.scopes);
await withTransaction(async (client) => {
await wipeDataToolScopes(client, scopes);
});
return getAdminDataToolsSummary();
}

View File

@@ -59,14 +59,17 @@ import {
deleteLifePostReaction,
deletePokemon,
deleteRecipe,
exportAdminData,
fetchPokemonData,
fetchPokemonImageOptions,
getAdminDataToolsSummary,
getHabitat,
getItem,
getOptions,
getPokemon,
getPublicUserProfile,
getRecipe,
importAdminData,
isConfigType,
listEntityDiscussionComments,
listConfig,
@@ -101,7 +104,8 @@ import {
updateLanguage,
updateLifePost,
updatePokemon,
updateRecipe
updateRecipe,
wipeAdminData
} from './queries.ts';
import {
getAiModerationSettings,
@@ -178,7 +182,7 @@ app.setErrorHandler(async (error, _request, reply) => {
return reply.code(409).send({ message: await serverMessage(locale, 'duplicate') });
}
if (pgError.code === '23514') {
if (pgError.code === '23502' || pgError.code === '23514') {
return reply.code(400).send({ message: await serverMessage(locale, 'invalidField') });
}
@@ -212,6 +216,287 @@ function requestLocale(request: FastifyRequest): string {
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(
locale: string,
key:
@@ -844,6 +1129,10 @@ app.get('/api/system-wordings', async (request) => getSystemWordings(requestLoca
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/users/:id/profile', async (request, reply) => {
@@ -1532,6 +1821,26 @@ app.put('/api/admin/rate-limits', async (request, reply) => {
return updateRateLimitSettings(request.body as Record<string, unknown>, user.id);
});
app.get('/api/admin/data-tools/summary', async (request, reply) => {
const user = await requireAnyPermission(request, reply, ['admin.data.export', 'admin.data.import']);
return user ? getAdminDataToolsSummary() : undefined;
});
app.post('/api/admin/data-tools/export', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.export', 'adminWrite');
return user ? exportAdminData(request.body as Record<string, unknown>) : undefined;
});
app.post('/api/admin/data-tools/import', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? importAdminData(request.body as Record<string, unknown>) : undefined;
});
app.post('/api/admin/data-tools/wipe', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
});
app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requirePermission(request, reply, 'admin.config.read');
if (!user) {

View File

@@ -9,6 +9,8 @@ export const iconCancel: AppIcon = 'mdi:close';
export const iconCheck: AppIcon = 'mdi:check';
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
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 iconComment: AppIcon = 'mdi:comment-outline';
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 iconError: AppIcon = 'mdi:close-circle-outline';
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 iconHome: AppIcon = 'mdi:home-variant-outline';
export const iconImage: AppIcon = 'mdi:image-outline';

View File

@@ -10,6 +10,7 @@ import RecipeList from '../views/RecipeList.vue';
import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifeView from '../views/LifeView.vue';
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
import LegalView from '../views/LegalView.vue';
import ComingSoonView from '../views/ComingSoonView.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: '/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',
component: LegalView,

View File

@@ -64,6 +64,48 @@ export interface UserSummary {
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 {
path: string;
url: string;
@@ -109,6 +151,8 @@ export interface EditHistoryEntry {
export interface Pokemon extends EditInfo {
id: number;
dataId?: number | null;
dataIdentifier?: string;
displayId: number;
name: string;
baseName?: string;
@@ -252,6 +296,24 @@ export interface DailyChecklistItem {
translations?: TranslationMap;
}
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
export interface DataToolScopeSummary {
scope: DataToolScope;
count: number;
}
export interface DataToolsSummary {
scopes: DataToolScopeSummary[];
}
export interface DataToolsBundle {
version: 1;
exportedAt: string;
scopes: DataToolScope[];
data: Partial<Record<DataToolScope, Record<string, unknown[]>>>;
}
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
export type LifeReactionCounts = Record<LifeReactionType, number>;
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
@@ -492,6 +554,8 @@ export type ConfigType =
| 'game-versions';
export interface PokemonPayload {
dataId?: number | null;
dataIdentifier?: string;
displayId: number;
isEventItem: boolean;
name: string;
@@ -836,6 +900,13 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
export const api = {
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'),
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
@@ -853,6 +924,10 @@ export const api = {
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),

View File

@@ -835,6 +835,59 @@ button:disabled,
gap: 12px;
}
.data-tool-grid {
display: grid;
gap: 0;
}
.data-tool-panel {
display: grid;
gap: 14px;
padding: 18px 0;
border-bottom: 1px solid var(--line);
}
.data-tool-panel:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.data-tool-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.data-tool-panel__header h3 {
margin: 0;
color: var(--ink);
font-size: 15px;
font-weight: 900;
}
.data-tool-scope-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px;
}
.data-tool-scope {
min-height: 44px;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
color: var(--ink-soft);
font-weight: 800;
}
.data-tool-scope input {
width: 18px;
height: 18px;
}
.pokemon-edit-form {
height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0;
@@ -4852,6 +4905,332 @@ button:disabled,
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 {
display: grid;
justify-items: center;
@@ -5898,6 +6277,21 @@ button:disabled,
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 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -5973,6 +6367,36 @@ button:disabled,
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 {
min-height: 420px;
margin: 12px;

View File

@@ -24,6 +24,7 @@ import {
iconRecipe,
iconSave,
iconTranslate,
iconUpload,
type AppIcon
} from '../icons';
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
@@ -36,6 +37,9 @@ import {
type AuthUser,
type AdminUser,
type ConfigType,
type DataToolScope,
type DataToolsBundle,
type DataToolsSummary,
type DailyChecklistItem,
type GameVersion,
type Habitat,
@@ -65,6 +69,7 @@ type AdminTab =
| 'permissions'
| 'rateLimits'
| 'aiModeration'
| 'dataTools'
| 'config'
| 'languages'
| 'wordings'
@@ -97,6 +102,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
'upload',
'fetch'
];
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
@@ -113,6 +119,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
permissions: iconKey,
rateLimits: iconAdmin,
aiModeration: iconAdmin,
dataTools: iconAdmin,
config: iconAdmin,
languages: iconTranslate,
wordings: iconTranslate,
@@ -140,7 +147,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
]
},
{
@@ -200,6 +208,7 @@ const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
const dataToolsSummary = ref<DataToolsSummary | null>(null);
const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
@@ -251,10 +260,16 @@ const userRoleModalOpen = ref(false);
const roleModalOpen = ref(false);
const rolePermissionsModalOpen = ref(false);
const permissionModalOpen = ref(false);
const dataToolImportModalOpen = ref(false);
const dataToolWipeModalOpen = ref(false);
const wordingLocale = ref(getCurrentLocale());
const wordingModule = ref('');
const wordingSurface = ref<SystemWordingSurface | ''>('');
const wordingMissingOnly = ref(false);
const selectedExportScopes = ref<DataToolScope[]>(['pokemon']);
const selectedWipeScopes = ref<DataToolScope[]>([]);
const pendingImportBundle = ref<DataToolsBundle | null>(null);
const dataToolConfirmText = ref('');
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
@@ -352,6 +367,23 @@ const rateLimitPolicyOptions = computed<Array<{ value: RateLimitPolicyKey; label
{ value: 'upload', label: t('pages.admin.rateLimitUpload') },
{ value: 'fetch', label: t('pages.admin.rateLimitFetch') }
]);
const dataToolScopeOptions = computed<Array<{ value: DataToolScope; label: string; count: number }>>(() =>
dataToolScopeKeys.map((scope) => ({
value: scope,
label: t(`pages.admin.dataToolScope${scope.charAt(0).toUpperCase()}${scope.slice(1)}`),
count: dataToolsSummary.value?.scopes.find((item) => item.scope === scope)?.count ?? 0
}))
);
const importScopeLabels = computed(() =>
(pendingImportBundle.value?.scopes ?? [])
.map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope)
.join(' / ')
);
const wipeScopeLabels = computed(() =>
selectedWipeScopes.value
.map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope)
.join(' / ')
);
const filteredWordingRows = computed(() =>
wordingRows.value.filter((item) => {
if (wordingModule.value && item.module !== wordingModule.value) return false;
@@ -383,6 +415,36 @@ function canAny(permissionKey: string | string[]) {
return Array.isArray(permissionKey) ? permissionKey.some((key) => can(key)) : can(permissionKey);
}
function toggleDataToolScope(values: DataToolScope[], scope: DataToolScope) {
const nextValues = new Set(values);
if (nextValues.has(scope)) {
nextValues.delete(scope);
} else {
nextValues.add(scope);
}
return normalizeDataToolScopes([...nextValues]);
}
function normalizeDataToolScopes(scopes: DataToolScope[]) {
const nextValues = new Set(scopes);
if (nextValues.has('items')) {
nextValues.add('recipes');
}
return dataToolScopeKeys.filter((item) => nextValues.has(item));
}
function dataToolScopeLocked(values: DataToolScope[], scope: DataToolScope) {
return scope === 'recipes' && values.includes('items');
}
function toggleExportScope(scope: DataToolScope) {
selectedExportScopes.value = toggleDataToolScope(selectedExportScopes.value, scope);
}
function toggleWipeScope(scope: DataToolScope) {
selectedWipeScopes.value = toggleDataToolScope(selectedWipeScopes.value, scope);
}
function dragSortLabel(name: string) {
return t('pages.admin.dragSort', { name });
}
@@ -928,6 +990,10 @@ async function loadRateLimitSettings() {
resetRateLimitForm(rateLimitSettings.value);
}
async function loadDataToolsSummary() {
dataToolsSummary.value = await api.dataToolsSummary();
}
async function reloadWordings() {
await run(loadWordings);
}
@@ -1051,6 +1117,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'wordings') await loadWordings();
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
if (activeTab.value === 'dataTools') await loadDataToolsSummary();
if (activeTab.value === 'checklist') await loadChecklist();
if (activeTab.value === 'pokemon') await loadPokemon();
if (activeTab.value === 'items') await loadItems();
@@ -1155,6 +1222,117 @@ async function removeHabitat(id: number) {
});
}
function downloadDataToolsBundle(bundle: DataToolsBundle) {
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `pokopia-data-${bundle.exportedAt.slice(0, 10)}.json`;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
async function exportDataTools() {
const scopes = normalizeDataToolScopes(selectedExportScopes.value);
if (!scopes.length) {
message.value = t('pages.admin.dataToolSelectScope');
return;
}
selectedExportScopes.value = scopes;
await run(async () => {
const bundle = await api.exportDataTools(scopes);
downloadDataToolsBundle(bundle);
});
}
function openWipeDataTools() {
const scopes = normalizeDataToolScopes(selectedWipeScopes.value);
if (!scopes.length) {
message.value = t('pages.admin.dataToolSelectScope');
return;
}
selectedWipeScopes.value = scopes;
dataToolConfirmText.value = '';
dataToolWipeModalOpen.value = true;
}
function closeWipeDataToolsModal() {
dataToolWipeModalOpen.value = false;
dataToolConfirmText.value = '';
}
async function confirmWipeDataTools() {
if (dataToolConfirmText.value !== 'WIPE') {
return;
}
await run(async () => {
dataToolsSummary.value = await api.wipeDataTools(normalizeDataToolScopes(selectedWipeScopes.value));
selectedWipeScopes.value = [];
closeWipeDataToolsModal();
});
}
function validDataToolsBundle(value: unknown): value is DataToolsBundle {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false;
}
const bundle = value as Partial<DataToolsBundle>;
return (
bundle.version === 1 &&
Array.isArray(bundle.scopes) &&
bundle.scopes.length > 0 &&
bundle.scopes.every((scope) => dataToolScopeKeys.includes(scope)) &&
Boolean(bundle.data) &&
typeof bundle.data === 'object' &&
!Array.isArray(bundle.data)
);
}
async function selectImportDataToolsFile(event: Event) {
const input = event.target instanceof HTMLInputElement ? event.target : null;
const file = input?.files?.[0];
if (input) {
input.value = '';
}
if (!file) {
return;
}
try {
const bundle = JSON.parse(await file.text()) as unknown;
if (!validDataToolsBundle(bundle)) {
message.value = t('pages.admin.dataToolInvalidBundle');
return;
}
pendingImportBundle.value = { ...bundle, scopes: normalizeDataToolScopes(bundle.scopes) };
dataToolConfirmText.value = '';
dataToolImportModalOpen.value = true;
} catch {
message.value = t('pages.admin.dataToolInvalidBundle');
}
}
function closeImportDataToolsModal() {
dataToolImportModalOpen.value = false;
pendingImportBundle.value = null;
dataToolConfirmText.value = '';
}
async function confirmImportDataTools() {
if (!pendingImportBundle.value || dataToolConfirmText.value !== 'IMPORT') {
return;
}
await run(async () => {
dataToolsSummary.value = await api.importDataTools(pendingImportBundle.value as DataToolsBundle);
closeImportDataToolsModal();
});
}
async function removeRole(id: number) {
await run(async () => {
await api.deleteRole(id);
@@ -1425,6 +1603,78 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'dataTools'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.dataTools') }}</h2>
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy" @click="run(loadDataToolsSummary)">
<Icon :icon="iconAdmin" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolRefresh') }}
</button>
</div>
<div class="data-tool-grid">
<section class="data-tool-panel" :aria-label="t('pages.admin.dataToolExport')">
<div class="data-tool-panel__header">
<h3>{{ t('pages.admin.dataToolExport') }}</h3>
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy || !can('admin.data.export')" @click="exportDataTools">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolExportButton') }}
</button>
</div>
<div class="data-tool-scope-list">
<label v-for="option in dataToolScopeOptions" :key="`export-${option.value}`" class="data-tool-scope">
<input
type="checkbox"
:checked="selectedExportScopes.includes(option.value)"
:disabled="busy || !can('admin.data.export') || dataToolScopeLocked(selectedExportScopes, option.value)"
@change="toggleExportScope(option.value)"
/>
<span>{{ option.label }}</span>
<span class="config-flag">{{ option.count }}</span>
</label>
</div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolUploadsNote') }}</p>
</section>
<section class="data-tool-panel" :aria-label="t('pages.admin.dataToolImport')">
<div class="data-tool-panel__header">
<h3>{{ t('pages.admin.dataToolImport') }}</h3>
</div>
<div class="field">
<label for="data-tools-import-file">{{ t('pages.admin.dataToolImportFile') }}</label>
<input id="data-tools-import-file" type="file" accept="application/json,.json" :disabled="busy || !can('admin.data.import')" @change="selectImportDataToolsFile" />
</div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p>
</section>
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
<div class="data-tool-panel__header">
<h3>{{ t('pages.admin.dataToolWipe') }}</h3>
<button type="button" class="ui-button ui-button--red ui-button--small" :disabled="busy || !can('admin.data.import')" @click="openWipeDataTools">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolWipeButton') }}
</button>
</div>
<div class="data-tool-scope-list">
<label v-for="option in dataToolScopeOptions" :key="`wipe-${option.value}`" class="data-tool-scope">
<input
type="checkbox"
:checked="selectedWipeScopes.includes(option.value)"
:disabled="busy || !can('admin.data.import') || dataToolScopeLocked(selectedWipeScopes, option.value)"
@change="toggleWipeScope(option.value)"
/>
<span>{{ option.label }}</span>
<span class="config-flag">{{ option.count }}</span>
</label>
</div>
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
<p class="meta-line">{{ t('pages.admin.dataToolReplaceNote') }}</p>
</section>
</div>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.config') }}</h2>
@@ -1802,6 +2052,44 @@ onMounted(() => {
</div>
</div>
<Modal v-if="dataToolImportModalOpen" :title="t('pages.admin.dataToolImport')" :close-label="t('common.close')" @close="closeImportDataToolsModal">
<form id="admin-data-tool-import-form" class="modal-edit-form" @submit.prevent="confirmImportDataTools">
<p class="meta-line">{{ t('pages.admin.dataToolImportConfirm', { scopes: importScopeLabels }) }}</p>
<div class="field">
<label for="data-tool-import-confirm">{{ t('pages.admin.dataToolConfirmImport') }}</label>
<input id="data-tool-import-confirm" v-model="dataToolConfirmText" autocomplete="off" required />
</div>
</form>
<template #footer>
<button type="button" class="link-button" :disabled="busy" @click="closeImportDataToolsModal">
{{ t('common.cancel') }}
</button>
<button type="submit" form="admin-data-tool-import-form" class="link-button" :disabled="busy || dataToolConfirmText !== 'IMPORT'">
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolImportButton') }}
</button>
</template>
</Modal>
<Modal v-if="dataToolWipeModalOpen" :title="t('pages.admin.dataToolWipe')" :close-label="t('common.close')" @close="closeWipeDataToolsModal">
<form id="admin-data-tool-wipe-form" class="modal-edit-form" @submit.prevent="confirmWipeDataTools">
<p class="meta-line">{{ t('pages.admin.dataToolWipeConfirm', { scopes: wipeScopeLabels }) }}</p>
<div class="field">
<label for="data-tool-wipe-confirm">{{ t('pages.admin.dataToolConfirmWipe') }}</label>
<input id="data-tool-wipe-confirm" v-model="dataToolConfirmText" autocomplete="off" required />
</div>
</form>
<template #footer>
<button type="button" class="link-button" :disabled="busy" @click="closeWipeDataToolsModal">
{{ t('common.cancel') }}
</button>
<button type="submit" form="admin-data-tool-wipe-form" class="link-button" :disabled="busy || dataToolConfirmText !== 'WIPE'">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('pages.admin.dataToolWipeButton') }}
</button>
</template>
</Modal>
<Modal v-if="userRoleModalOpen" :title="userRoleModalTitle" :close-label="t('common.close')" size="wide" @close="closeUserRoleModal">
<form id="admin-user-roles-form" class="modal-edit-form" @submit.prevent="saveUserRoles">
<div v-if="editingUser" class="access-modal-heading">

View File

@@ -1,25 +1,34 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PokeBallMark from '../components/PokeBallMark.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusBadge from '../components/StatusBadge.vue';
import {
iconAction,
iconAutomation,
iconChevronRight,
iconChecklist,
iconClothes,
iconDish,
iconDreamIsland,
iconEvent,
iconExternal,
iconGitCommit,
iconHabitat,
iconItem,
iconLife,
iconPokemon,
iconRecipe
} 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(() => [
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
@@ -42,6 +51,17 @@ const futureSections = computed(() => [
{ 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) {
return `pages.home.sections.${key}.title`;
}
@@ -49,6 +69,34 @@ function sectionTitleKey(key: string) {
function sectionDescriptionKey(key: string) {
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>
<template>
@@ -145,6 +193,84 @@ function sectionDescriptionKey(key: string) {
</div>
</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">
<div class="home-section__header">
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>

View File

@@ -79,6 +79,8 @@ function defaultPokemonStats(): PokemonStats {
}
const pokemonForm = ref({
dataId: null as number | null,
dataIdentifier: '',
id: '',
isEventItem: false,
name: '',
@@ -257,8 +259,22 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine
}
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
const routePokemonId = Number(routeId.value);
if (
isEditing.value &&
!pokemonForm.value.isEventItem &&
Number.isInteger(routePokemonId) &&
routePokemonId > 0 &&
fetchedPokemon.id !== routePokemonId
) {
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
return false;
}
pokemonForm.value = {
...pokemonForm.value,
dataId: fetchedPokemon.id,
dataIdentifier: fetchedPokemon.identifier,
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
name: fetchedPokemon.name,
genus: fetchedPokemon.genus,
@@ -295,6 +311,8 @@ async function loadEditor() {
if (isEditing.value) {
const pokemon = await api.pokemonDetail(routeId.value);
pokemonForm.value = {
dataId: pokemon.dataId ?? null,
dataIdentifier: pokemon.dataIdentifier ?? '',
id: String(pokemon.displayId),
isEventItem: pokemon.isEventItem,
name: pokemon.baseName ?? pokemon.name,
@@ -678,6 +696,8 @@ async function savePokemon() {
try {
const payload: PokemonPayload = {
dataId: pokemonForm.value.dataId,
dataIdentifier: pokemonForm.value.dataIdentifier,
displayId: pokemonIdForSave(),
isEventItem: pokemonForm.value.isEventItem,
name: pokemonNameForSave(),

View 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>

View File

@@ -157,6 +157,15 @@ export const systemWordingMessages = {
wikiTitle: 'Browse game records',
communityKicker: 'Daily & Community',
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',
futureTitle: 'Planned wiki areas',
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: {
lastUpdated: 'Last updated: May 3, 2026',
sourceLinks: 'Source and reference links',
@@ -805,6 +832,30 @@ export const systemWordingMessages = {
itemList: 'Item list',
recipeList: 'Recipe list',
habitatList: 'Habitat list',
dataTools: 'Data tools',
dataToolRefresh: 'Refresh',
dataToolExport: 'Export data',
dataToolExportButton: 'Export JSON',
dataToolImport: 'Import data',
dataToolImportButton: 'Import',
dataToolImportFile: 'Data bundle',
dataToolImportMode: 'Import replaces the scopes included in the bundle.',
dataToolWipe: 'Wipe data',
dataToolWipeButton: 'Wipe',
dataToolSelectScope: 'Select at least one data scope.',
dataToolInvalidBundle: 'Data bundle is invalid.',
dataToolImportConfirm: 'Import will replace: {scopes}.',
dataToolWipeConfirm: 'Wipe will delete: {scopes}.',
dataToolConfirmImport: 'Type IMPORT to confirm',
dataToolConfirmWipe: 'Type WIPE to confirm',
dataToolDependencyNote: 'Items include Recipes because recipes depend on items.',
dataToolReplaceNote: 'Related records, translations, edit history, image history, and discussions are included.',
dataToolUploadsNote: 'Uploaded files are not included in JSON exports.',
dataToolScopePokemon: 'Pokemon',
dataToolScopeHabitats: 'Habitats',
dataToolScopeItems: 'Items',
dataToolScopeRecipes: 'Recipes',
dataToolScopeChecklist: 'Daily CheckList',
languages: 'Languages',
newConfig: 'New {name}',
editConfig: 'Edit {name}',
@@ -1027,6 +1078,10 @@ export const systemWordingMessages = {
pokemonIdentifierRequired: 'Pokemon identifier is required',
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
pokemonDataNotFound: 'Pokemon data was not found',
pokemonDataIdMismatch: 'Pokemon data ID does not match this Pokemon',
dataToolScopeRequired: 'Select at least one data scope',
dataToolScopeInvalid: 'Data scope is invalid',
dataToolBundleInvalid: 'Data bundle is invalid',
pokemonImagePathInvalid: 'Pokemon image path is invalid',
imagePathInvalid: 'Image path is invalid',
imageUploadRequired: 'Please select an image',
@@ -1263,6 +1318,15 @@ export const systemWordingMessages = {
wikiTitle: '浏览游戏资料',
communityKicker: '每日与社区',
communityTitle: '查看每日任务和社区更新',
projectUpdatesKicker: '项目更新',
projectUpdatesTitle: '最近站点改动',
projectUpdatesRepo: '源码仓库',
projectUpdatesUpdatedAt: '更新于 {date}',
projectUpdatesCommits: '最近提交',
projectUpdatesReleases: '发布版本',
projectUpdatesViewCommit: '查看提交',
projectUpdatesViewRelease: '查看发布',
projectUpdatesViewAll: '查看全部',
futureKicker: '更多分区',
futureTitle: '规划中的 Wiki 区域',
sections: {
@@ -1316,6 +1380,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: {
lastUpdated: '最后更新2026年5月3日',
sourceLinks: '来源与参考链接',
@@ -1891,6 +1973,30 @@ export const systemWordingMessages = {
itemList: '物品列表',
recipeList: '材料单列表',
habitatList: '栖息地列表',
dataTools: '数据工具',
dataToolRefresh: '刷新',
dataToolExport: '导出数据',
dataToolExportButton: '导出 JSON',
dataToolImport: '导入数据',
dataToolImportButton: '导入',
dataToolImportFile: '数据包',
dataToolImportMode: '导入会替换数据包内包含的范围。',
dataToolWipe: '清空数据',
dataToolWipeButton: '清空',
dataToolSelectScope: '请至少选择一个数据范围。',
dataToolInvalidBundle: '数据包不合法。',
dataToolImportConfirm: '导入将替换:{scopes}。',
dataToolWipeConfirm: '清空将删除:{scopes}。',
dataToolConfirmImport: '输入 IMPORT 确认',
dataToolConfirmWipe: '输入 WIPE 确认',
dataToolDependencyNote: '物品会连同材料单一起处理,因为材料单依赖物品。',
dataToolReplaceNote: '关联记录、翻译、编辑历史、图片历史和讨论会一并处理。',
dataToolUploadsNote: 'JSON 导出不包含上传文件本身。',
dataToolScopePokemon: 'Pokemon',
dataToolScopeHabitats: '栖息地',
dataToolScopeItems: '物品',
dataToolScopeRecipes: '材料单',
dataToolScopeChecklist: '每日 CheckList',
languages: '语言',
newConfig: '新增{name}',
editConfig: '编辑{name}',
@@ -2113,6 +2219,10 @@ export const systemWordingMessages = {
pokemonIdentifierRequired: '请输入 Pokemon 标识',
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
pokemonDataNotFound: '未找到 Pokemon 数据',
pokemonDataIdMismatch: 'Pokemon 数据 ID 与当前 Pokemon 不一致',
dataToolScopeRequired: '请至少选择一个数据范围',
dataToolScopeInvalid: '数据范围不合法',
dataToolBundleInvalid: '数据包不合法',
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
imagePathInvalid: '图片路径不合法',
imageUploadRequired: '请选择图片',