Compare commits
3 Commits
a0e07f101a
...
f2a8b67ebf
| Author | SHA1 | Date | |
|---|---|---|---|
| f2a8b67ebf | |||
| fa06d24826 | |||
| 8dfd03f3d2 |
70
DESIGN.md
70
DESIGN.md
@@ -184,6 +184,42 @@
|
|||||||
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
|
- 非 Owner 即使拥有 `admin.users.update` 或自定义高等级角色,也不能分配或移除 `owner` 角色。
|
||||||
- 管理 API 只返回权限管理所需字段,不返回密码、session token hash、verification/reset token hash、内部审计 payload 或调试字段。
|
- 管理 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 主表重置自增 ID;Pokemon 内部 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
|
||||||
|
|
||||||
- Referral 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。
|
- Referral 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。
|
||||||
@@ -407,7 +443,8 @@
|
|||||||
|
|
||||||
Pokemon 可配置:
|
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`
|
- 展示 ID:`display_id`,详情页、列表卡片和选择器中显示为 `#ID`
|
||||||
- 是否为活动物品:`is_event_item`
|
- 是否为活动物品:`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 编辑表单使用标签页组织字段:
|
Pokemon 编辑表单使用标签页组织字段:
|
||||||
|
|
||||||
@@ -442,8 +479,9 @@ Pokemon 编辑表单使用标签页组织字段:
|
|||||||
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
||||||
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
||||||
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
- 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 data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID,只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。
|
||||||
|
- Fetch 后保存普通 Pokemon 时,官方 data ID 作为内部路由 ID;展示 ID 只保存到 `display_id`。
|
||||||
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
- 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 根据 `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 配置。
|
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
||||||
@@ -746,6 +784,24 @@ API 暴露边界:
|
|||||||
- Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。
|
- Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。
|
||||||
- 法律页面和页脚文案必须通过系统级文案 catalog 管理,并支持现有语言回退机制。
|
- 法律页面和页脚文案必须通过系统级文案 catalog 管理,并支持现有语言回退机制。
|
||||||
|
|
||||||
|
## 项目更新展示
|
||||||
|
|
||||||
|
- Home 首页可展示 Pokopia Wiki 站点项目的公开更新信息,用于让访客了解站点代码与发布进展。
|
||||||
|
- 完整项目更新页路径为 `/project-updates`,由 Home 首页项目更新预览区的 View All 入口进入。
|
||||||
|
- 更新信息来源为公开 Gitea 仓库 `https://git.tootaio.com/Kingsmai/pokopiawiki.tootaio.com`。
|
||||||
|
- 前端不得直接读取 Gitea API;后端通过 `GET /api/project-updates` 代理并净化公开仓库数据。
|
||||||
|
- 项目更新 API 只返回展示所需字段:
|
||||||
|
- 仓库:`name`、`fullName`、公开仓库 `url`、`defaultBranch`、`updatedAt`。
|
||||||
|
- 最近提交分页:`items`、`nextCursor`、`hasMore`;每条提交只包含 `sha`、`shortSha`、提交标题 `title`、完整提交消息 `message`、`createdAt`、不含邮箱的 `authorName`、公开提交 `url`。
|
||||||
|
- 发布版本:`tagName`、`name`、`publishedAt`、公开发布 `url`。
|
||||||
|
- 最近提交支持 `limit` 和不透明 `cursor` 增量读取;前端不得依赖 Gitea 的 `page` / `limit` 实现细节。
|
||||||
|
- 项目更新 API 不返回 Gitea token、用户邮箱、内部 API URL、内网地址、文件列表、提交统计、Actions 日志、构建日志或调试字段。
|
||||||
|
- Home 首页默认展示最近提交预览;用户可通过 View All 进入 `/project-updates` 完整页面。
|
||||||
|
- `/project-updates` 按 Life Post 相同的增量方式继续显示更多提交。
|
||||||
|
- `/project-updates` 的每条提交默认折叠,仅展示标题、短 SHA、作者和时间;用户可展开单条提交查看完整 Commit Message,并可再次收起。
|
||||||
|
- 若仓库后续提供 Release,可展示发布版本。没有 Release 时不展示空发布区块。
|
||||||
|
- Gitea 读取失败时不得在前台展示内部错误或调试信息。
|
||||||
|
|
||||||
## 前端交互与 UI
|
## 前端交互与 UI
|
||||||
|
|
||||||
- UI 风格以 `DesignGuidelines.html` 为准。
|
- UI 风格以 `DesignGuidelines.html` 为准。
|
||||||
@@ -753,7 +809,7 @@ API 暴露边界:
|
|||||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||||
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
||||||
- 配置:System config。
|
- 配置:System config。
|
||||||
- 内容:Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。
|
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。
|
||||||
- 本地化:Languages、System wordings。
|
- 本地化:Languages、System wordings。
|
||||||
- 访问权限:Users、Roles、Permissions、Rate limits。
|
- 访问权限:Users、Roles、Permissions、Rate limits。
|
||||||
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||||
@@ -792,6 +848,7 @@ API 暴露边界:
|
|||||||
- `/recipes`
|
- `/recipes`
|
||||||
- `/checklist`
|
- `/checklist`
|
||||||
- `/life`
|
- `/life`
|
||||||
|
- `/project-updates`
|
||||||
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
||||||
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
||||||
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
||||||
@@ -806,6 +863,7 @@ API 暴露边界:
|
|||||||
- `GET /api/languages`
|
- `GET /api/languages`
|
||||||
- `GET /api/system-wordings`
|
- `GET /api/system-wordings`
|
||||||
- `GET /api/options`
|
- `GET /api/options`
|
||||||
|
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
||||||
- `GET /api/daily-checklist`
|
- `GET /api/daily-checklist`
|
||||||
- `GET /api/pokemon`
|
- `GET /api/pokemon`
|
||||||
- `GET /api/pokemon/:id`
|
- `GET /api/pokemon/:id`
|
||||||
@@ -848,6 +906,10 @@ API 暴露边界:
|
|||||||
- `POST /api/admin/permissions`:需要 `admin.permissions.create`
|
- `POST /api/admin/permissions`:需要 `admin.permissions.create`
|
||||||
- `PUT /api/admin/permissions/:id`:需要 `admin.permissions.update`
|
- `PUT /api/admin/permissions/:id`:需要 `admin.permissions.update`
|
||||||
- `DELETE /api/admin/permissions/:id`:需要 `admin.permissions.delete`
|
- `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:
|
受权限保护的编辑 API:
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,8 @@ VALUES
|
|||||||
('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
|
('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.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.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.create', 'Create checklist tasks', 'Create Daily CheckList tasks.', 'CheckList', true),
|
||||||
('checklist.update', 'Update checklist tasks', 'Edit 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),
|
('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 (
|
CREATE TABLE IF NOT EXISTS pokemon (
|
||||||
id integer PRIMARY KEY,
|
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),
|
display_id integer NOT NULL CHECK (display_id > 0),
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
is_event_item boolean NOT NULL DEFAULT false,
|
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()
|
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 (
|
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
|
||||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||||
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
|
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ type QueryValue = string | string[] | undefined;
|
|||||||
type QueryParams = Record<string, QueryValue>;
|
type QueryParams = Record<string, QueryValue>;
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
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 TranslationField = 'name' | 'title' | 'details' | 'genus';
|
||||||
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
||||||
@@ -110,6 +123,8 @@ type PokemonImageOptionsResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type PokemonPayload = {
|
type PokemonPayload = {
|
||||||
|
dataId: number | null;
|
||||||
|
dataIdentifier: string;
|
||||||
displayId: number;
|
displayId: number;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -897,28 +912,19 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise<numbe
|
|||||||
return result.rows[0]?.sortOrder ?? 10;
|
return result.rows[0]?.sortOrder ?? 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function nextPokemonInternalId(client: DbClient, displayId: number, isEventItem: boolean): Promise<number> {
|
async function nextPokemonInternalId(
|
||||||
if (isEventItem) {
|
client: DbClient,
|
||||||
const result = await client.query<{ id: number }>(
|
dataId: number | null,
|
||||||
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
|
isEventItem: boolean
|
||||||
);
|
): Promise<number> {
|
||||||
const nextId = result.rows[0]?.id ?? 1000000;
|
if (!isEventItem && dataId !== null) {
|
||||||
return nextId === displayId ? nextId + 1 : nextId;
|
return dataId;
|
||||||
}
|
|
||||||
|
|
||||||
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 }>(
|
const result = await client.query<{ id: number }>(
|
||||||
'SELECT COALESCE(MAX(id), 0) + 1 AS id FROM pokemon WHERE id <> $1',
|
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
|
||||||
[displayId]
|
|
||||||
);
|
);
|
||||||
const nextId = result.rows[0]?.id ?? 1;
|
return result.rows[0]?.id ?? 1000000;
|
||||||
return nextId === displayId ? nextId + 1 : nextId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reorderTableRows(
|
async function reorderTableRows(
|
||||||
@@ -2160,6 +2166,8 @@ function pokemonProjection(locale: string): string {
|
|||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
|
p.data_id AS "dataId",
|
||||||
|
p.data_identifier AS "dataIdentifier",
|
||||||
p.display_id AS "displayId",
|
p.display_id AS "displayId",
|
||||||
${pokemonName} AS name,
|
${pokemonName} AS name,
|
||||||
p.name AS "baseName",
|
p.name AS "baseName",
|
||||||
@@ -4684,6 +4692,8 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
|||||||
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
|
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
|
||||||
|
dataIdentifier: cleanOptionalText(payload.dataIdentifier),
|
||||||
displayId,
|
displayId,
|
||||||
isEventItem: Boolean(payload.isEventItem),
|
isEventItem: Boolean(payload.isEventItem),
|
||||||
name: cleanName(payload.name, 'server.validation.pokemonNameRequired'),
|
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> {
|
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_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
|
||||||
await client.query('DELETE FROM pokemon_pokemon_types 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) {
|
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
const cleanPayload = cleanPokemonPayload(payload);
|
const cleanPayload = cleanPokemonPayload(payload);
|
||||||
|
await normalizePokemonDataIdentity(cleanPayload);
|
||||||
|
|
||||||
const id = await withTransaction(async (client) => {
|
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');
|
const sortOrder = await nextSortOrder(client, 'pokemon');
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
INSERT INTO pokemon (
|
INSERT INTO pokemon (
|
||||||
id,
|
id,
|
||||||
|
data_id,
|
||||||
|
data_identifier,
|
||||||
display_id,
|
display_id,
|
||||||
name,
|
name,
|
||||||
is_event_item,
|
is_event_item,
|
||||||
@@ -4780,10 +4808,12 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
|||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
updated_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,
|
pokemonId,
|
||||||
|
cleanPayload.dataId,
|
||||||
|
cleanPayload.dataIdentifier,
|
||||||
cleanPayload.displayId,
|
cleanPayload.displayId,
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
cleanPayload.isEventItem,
|
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) {
|
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
const cleanPayload = cleanPokemonPayload(payload);
|
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 before = await getPokemon(id, defaultLocale);
|
||||||
|
|
||||||
const updated = await withTransaction(async (client) => {
|
const updated = await withTransaction(async (client) => {
|
||||||
@@ -4825,30 +4859,34 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
|||||||
`
|
`
|
||||||
UPDATE pokemon
|
UPDATE pokemon
|
||||||
SET
|
SET
|
||||||
display_id = $1,
|
data_id = $1,
|
||||||
name = $2,
|
data_identifier = $2,
|
||||||
is_event_item = $3,
|
display_id = $3,
|
||||||
genus = $4,
|
name = $4,
|
||||||
details = $5,
|
is_event_item = $5,
|
||||||
height_inches = $6,
|
genus = $6,
|
||||||
weight_pounds = $7,
|
details = $7,
|
||||||
environment_id = $8,
|
height_inches = $8,
|
||||||
hp = $9,
|
weight_pounds = $9,
|
||||||
attack = $10,
|
environment_id = $10,
|
||||||
defense = $11,
|
hp = $11,
|
||||||
special_attack = $12,
|
attack = $12,
|
||||||
special_defense = $13,
|
defense = $13,
|
||||||
speed = $14,
|
special_attack = $14,
|
||||||
image_path = $15,
|
special_defense = $15,
|
||||||
image_style = $16,
|
speed = $16,
|
||||||
image_version = $17,
|
image_path = $17,
|
||||||
image_variant = $18,
|
image_style = $18,
|
||||||
image_description = $19,
|
image_version = $19,
|
||||||
updated_by_user_id = $20,
|
image_variant = $20,
|
||||||
|
image_description = $21,
|
||||||
|
updated_by_user_id = $22,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $21
|
WHERE id = $23
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
|
cleanPayload.dataId,
|
||||||
|
cleanPayload.dataIdentifier,
|
||||||
cleanPayload.displayId,
|
cleanPayload.displayId,
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
cleanPayload.isEventItem,
|
cleanPayload.isEventItem,
|
||||||
@@ -5736,3 +5774,444 @@ export async function deleteRecipe(id: number, userId: number) {
|
|||||||
return true;
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,14 +59,17 @@ import {
|
|||||||
deleteLifePostReaction,
|
deleteLifePostReaction,
|
||||||
deletePokemon,
|
deletePokemon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
|
exportAdminData,
|
||||||
fetchPokemonData,
|
fetchPokemonData,
|
||||||
fetchPokemonImageOptions,
|
fetchPokemonImageOptions,
|
||||||
|
getAdminDataToolsSummary,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
getItem,
|
getItem,
|
||||||
getOptions,
|
getOptions,
|
||||||
getPokemon,
|
getPokemon,
|
||||||
getPublicUserProfile,
|
getPublicUserProfile,
|
||||||
getRecipe,
|
getRecipe,
|
||||||
|
importAdminData,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
listEntityDiscussionComments,
|
listEntityDiscussionComments,
|
||||||
listConfig,
|
listConfig,
|
||||||
@@ -101,7 +104,8 @@ import {
|
|||||||
updateLanguage,
|
updateLanguage,
|
||||||
updateLifePost,
|
updateLifePost,
|
||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe
|
updateRecipe,
|
||||||
|
wipeAdminData
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
import {
|
import {
|
||||||
getAiModerationSettings,
|
getAiModerationSettings,
|
||||||
@@ -178,7 +182,7 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
return reply.code(409).send({ message: await serverMessage(locale, 'duplicate') });
|
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') });
|
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));
|
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectUpdatesRepository = {
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
url: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdateCommit = {
|
||||||
|
sha: string;
|
||||||
|
shortSha: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
authorName: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdateRelease = {
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectCommitPage = {
|
||||||
|
items: ProjectUpdateCommit[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdatesCursor = {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectUpdatesResponse = {
|
||||||
|
repository: ProjectUpdatesRepository;
|
||||||
|
commits: ProjectCommitPage;
|
||||||
|
releases: ProjectUpdateRelease[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectUpdatesConfig = {
|
||||||
|
apiBaseUrl: 'https://git.tootaio.com/api/v1',
|
||||||
|
publicBaseUrl: 'https://git.tootaio.com',
|
||||||
|
owner: 'Kingsmai',
|
||||||
|
repo: 'pokopiawiki.tootaio.com',
|
||||||
|
commitLimit: 5,
|
||||||
|
maxCommitLimit: 20,
|
||||||
|
releaseLimit: 3,
|
||||||
|
timeoutMs: 5000
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function projectRepositoryPath(): string {
|
||||||
|
return `${encodeURIComponent(projectUpdatesConfig.owner)}/${encodeURIComponent(projectUpdatesConfig.repo)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectRepositoryUrl(): string {
|
||||||
|
return `${projectUpdatesConfig.publicBaseUrl}/${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectApiUrl(path = '', params: Record<string, string | number | boolean> = {}): string {
|
||||||
|
const apiBaseUrl = projectUpdatesConfig.apiBaseUrl.replace(/\/$/, '');
|
||||||
|
const url = new URL(`${apiBaseUrl}/repos/${projectRepositoryPath()}${path}`);
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectField(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
|
||||||
|
if (!record) return null;
|
||||||
|
const value = record[key];
|
||||||
|
return isObjectRecord(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringField(record: Record<string, unknown> | null, key: string): string | null {
|
||||||
|
if (!record) return null;
|
||||||
|
const value = record[key];
|
||||||
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedDate(value: string | null): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectCommitTitle(message: string | null, fallback: string): string {
|
||||||
|
const [firstLine] = (message ?? '').split('\n');
|
||||||
|
return firstLine?.trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectUpdatesQueryValue(value: string | string[] | undefined): string | null {
|
||||||
|
const rawValue = Array.isArray(value) ? value[0] : value;
|
||||||
|
return rawValue?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanProjectUpdatesLimit(query: Record<string, string | string[] | undefined>): number {
|
||||||
|
const rawLimit = Number(projectUpdatesQueryValue(query.limit));
|
||||||
|
if (!Number.isInteger(rawLimit)) {
|
||||||
|
return projectUpdatesConfig.commitLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(Math.max(rawLimit, 1), projectUpdatesConfig.maxCommitLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeProjectUpdatesCursor(page: number, limit: number): string {
|
||||||
|
return Buffer.from(JSON.stringify({ page, limit }), 'utf8').toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeProjectUpdatesCursor(cursor: string | null): ProjectUpdatesCursor | null {
|
||||||
|
if (!cursor) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||||
|
if (!isObjectRecord(payload)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, limit } = payload;
|
||||||
|
if (
|
||||||
|
typeof page === 'number' &&
|
||||||
|
Number.isInteger(page) &&
|
||||||
|
page > 0 &&
|
||||||
|
typeof limit === 'number' &&
|
||||||
|
Number.isInteger(limit) &&
|
||||||
|
limit > 0 &&
|
||||||
|
limit <= projectUpdatesConfig.maxCommitLimit
|
||||||
|
) {
|
||||||
|
return { page, limit };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackProjectRepository(): ProjectUpdatesRepository {
|
||||||
|
return {
|
||||||
|
name: projectUpdatesConfig.repo,
|
||||||
|
fullName: `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
|
||||||
|
url: projectRepositoryUrl(),
|
||||||
|
defaultBranch: 'main',
|
||||||
|
updatedAt: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectJson(path = '', params: Record<string, string | number | boolean> = {}): Promise<unknown> {
|
||||||
|
const response = await fetch(projectApiUrl(path, params), {
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json'
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(projectUpdatesConfig.timeoutMs)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Project updates source failed (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectRepository(value: unknown): ProjectUpdatesRepository {
|
||||||
|
if (!isObjectRecord(value)) {
|
||||||
|
return fallbackProjectRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: stringField(value, 'name') ?? projectUpdatesConfig.repo,
|
||||||
|
fullName: stringField(value, 'full_name') ?? `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
|
||||||
|
url: projectRepositoryUrl(),
|
||||||
|
defaultBranch: stringField(value, 'default_branch') ?? 'main',
|
||||||
|
updatedAt: normalizedDate(stringField(value, 'updated_at'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectCommit(value: unknown): ProjectUpdateCommit | null {
|
||||||
|
if (!isObjectRecord(value)) return null;
|
||||||
|
|
||||||
|
const sha = stringField(value, 'sha');
|
||||||
|
if (!sha) return null;
|
||||||
|
|
||||||
|
const commit = objectField(value, 'commit');
|
||||||
|
const commitAuthor = objectField(commit, 'author');
|
||||||
|
const message = stringField(commit, 'message') ?? sha.slice(0, 7);
|
||||||
|
const fallback = sha.slice(0, 7);
|
||||||
|
const createdAt =
|
||||||
|
normalizedDate(stringField(value, 'created')) ??
|
||||||
|
normalizedDate(stringField(commitAuthor, 'date')) ??
|
||||||
|
normalizedDate(stringField(objectField(commit, 'committer'), 'date'));
|
||||||
|
|
||||||
|
if (!createdAt) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sha,
|
||||||
|
shortSha: sha.slice(0, 7),
|
||||||
|
title: projectCommitTitle(message, fallback),
|
||||||
|
message,
|
||||||
|
createdAt,
|
||||||
|
authorName:
|
||||||
|
stringField(commitAuthor, 'name') ??
|
||||||
|
stringField(objectField(value, 'author'), 'login') ??
|
||||||
|
projectUpdatesConfig.owner,
|
||||||
|
url: `${projectRepositoryUrl()}/commit/${sha}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectRelease(value: unknown): ProjectUpdateRelease | null {
|
||||||
|
if (!isObjectRecord(value)) return null;
|
||||||
|
|
||||||
|
const tagName = stringField(value, 'tag_name');
|
||||||
|
if (!tagName) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
name: stringField(value, 'name') ?? tagName,
|
||||||
|
publishedAt: normalizedDate(stringField(value, 'published_at')) ?? normalizedDate(stringField(value, 'created_at')),
|
||||||
|
url: `${projectRepositoryUrl()}/releases/tag/${encodeURIComponent(tagName)}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logProjectUpdatesError(source: string, error: unknown): void {
|
||||||
|
app.log.warn({ err: error, source }, 'Project updates source unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectCommitPage(query: Record<string, string | string[] | undefined>): Promise<ProjectCommitPage> {
|
||||||
|
const cursor = decodeProjectUpdatesCursor(projectUpdatesQueryValue(query.cursor));
|
||||||
|
const limit = cursor?.limit ?? cleanProjectUpdatesLimit(query);
|
||||||
|
const page = cursor?.page ?? 1;
|
||||||
|
const value = await fetchProjectJson('/commits', {
|
||||||
|
page,
|
||||||
|
limit: limit + 1,
|
||||||
|
stat: false,
|
||||||
|
files: false,
|
||||||
|
verification: false
|
||||||
|
});
|
||||||
|
const commits = Array.isArray(value)
|
||||||
|
? value.map(mapProjectCommit).filter((commit): commit is ProjectUpdateCommit => commit !== null)
|
||||||
|
: [];
|
||||||
|
const hasMore = commits.length > limit;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: commits.slice(0, limit),
|
||||||
|
nextCursor: hasMore ? encodeProjectUpdatesCursor(page + 1, limit) : null,
|
||||||
|
hasMore
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjectUpdates(query: Record<string, string | string[] | undefined> = {}): Promise<ProjectUpdatesResponse> {
|
||||||
|
const [repository, commits, releases] = await Promise.all([
|
||||||
|
fetchProjectJson()
|
||||||
|
.then(mapProjectRepository)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logProjectUpdatesError('repository', error);
|
||||||
|
return fallbackProjectRepository();
|
||||||
|
}),
|
||||||
|
getProjectCommitPage(query).catch((error: unknown) => {
|
||||||
|
logProjectUpdatesError('commits', error);
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
fetchProjectJson('/releases', { limit: projectUpdatesConfig.releaseLimit, draft: false, 'pre-release': false })
|
||||||
|
.then((value) =>
|
||||||
|
Array.isArray(value) ? value.map(mapProjectRelease).filter((release): release is ProjectUpdateRelease => release !== null) : []
|
||||||
|
)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
logProjectUpdatesError('releases', error);
|
||||||
|
return [];
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { repository, commits, releases };
|
||||||
|
}
|
||||||
|
|
||||||
function serverMessage(
|
function serverMessage(
|
||||||
locale: string,
|
locale: string,
|
||||||
key:
|
key:
|
||||||
@@ -844,6 +1129,10 @@ app.get('/api/system-wordings', async (request) => getSystemWordings(requestLoca
|
|||||||
|
|
||||||
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||||
|
|
||||||
|
app.get('/api/project-updates', async (request) =>
|
||||||
|
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||||
@@ -1532,6 +1821,26 @@ app.put('/api/admin/rate-limits', async (request, reply) => {
|
|||||||
return updateRateLimitSettings(request.body as Record<string, unknown>, user.id);
|
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) => {
|
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||||
const user = await requirePermission(request, reply, 'admin.config.read');
|
const user = await requirePermission(request, reply, 'admin.config.read');
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export const iconCancel: AppIcon = 'mdi:close';
|
|||||||
export const iconCheck: AppIcon = 'mdi:check';
|
export const iconCheck: AppIcon = 'mdi:check';
|
||||||
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||||
|
export const iconChevronRight: AppIcon = 'mdi:chevron-right';
|
||||||
|
export const iconChevronUp: AppIcon = 'mdi:chevron-up';
|
||||||
export const iconClose: AppIcon = 'mdi:close';
|
export const iconClose: AppIcon = 'mdi:close';
|
||||||
export const iconComment: AppIcon = 'mdi:comment-outline';
|
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||||
export const iconCopy: AppIcon = 'mdi:content-copy';
|
export const iconCopy: AppIcon = 'mdi:content-copy';
|
||||||
@@ -19,6 +21,8 @@ export const iconDreamIsland: AppIcon = 'mdi:palm-tree';
|
|||||||
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
||||||
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||||
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||||
|
export const iconExternal: AppIcon = 'mdi:open-in-new';
|
||||||
|
export const iconGitCommit: AppIcon = 'mdi:source-commit';
|
||||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
export const iconHome: AppIcon = 'mdi:home-variant-outline';
|
export const iconHome: AppIcon = 'mdi:home-variant-outline';
|
||||||
export const iconImage: AppIcon = 'mdi:image-outline';
|
export const iconImage: AppIcon = 'mdi:image-outline';
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import RecipeList from '../views/RecipeList.vue';
|
|||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
import LifeView from '../views/LifeView.vue';
|
import LifeView from '../views/LifeView.vue';
|
||||||
|
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
||||||
import LegalView from '../views/LegalView.vue';
|
import LegalView from '../views/LegalView.vue';
|
||||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
@@ -180,6 +181,17 @@ export const router = createRouter({
|
|||||||
},
|
},
|
||||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
||||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
||||||
|
{
|
||||||
|
path: '/project-updates',
|
||||||
|
component: ProjectUpdatesView,
|
||||||
|
meta: {
|
||||||
|
seo: seo({
|
||||||
|
titleKey: 'pages.projectUpdates.title',
|
||||||
|
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||||
|
canonicalPath: '/project-updates'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/privacy-policy',
|
path: '/privacy-policy',
|
||||||
component: LegalView,
|
component: LegalView,
|
||||||
|
|||||||
@@ -64,6 +64,48 @@ export interface UserSummary {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdatesRepository {
|
||||||
|
name: string;
|
||||||
|
fullName: string;
|
||||||
|
url: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdateCommit {
|
||||||
|
sha: string;
|
||||||
|
shortSha: string;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
authorName: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdateRelease {
|
||||||
|
tagName: string;
|
||||||
|
name: string;
|
||||||
|
publishedAt: string | null;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectCommitPage {
|
||||||
|
items: ProjectUpdateCommit[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdates {
|
||||||
|
repository: ProjectUpdatesRepository;
|
||||||
|
commits: ProjectCommitPage;
|
||||||
|
releases: ProjectUpdateRelease[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectUpdatesParams {
|
||||||
|
cursor?: string | null;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EntityImage {
|
export interface EntityImage {
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -109,6 +151,8 @@ export interface EditHistoryEntry {
|
|||||||
|
|
||||||
export interface Pokemon extends EditInfo {
|
export interface Pokemon extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
|
dataId?: number | null;
|
||||||
|
dataIdentifier?: string;
|
||||||
displayId: number;
|
displayId: number;
|
||||||
name: string;
|
name: string;
|
||||||
baseName?: string;
|
baseName?: string;
|
||||||
@@ -252,6 +296,24 @@ export interface DailyChecklistItem {
|
|||||||
translations?: TranslationMap;
|
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 LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||||
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||||
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
||||||
@@ -492,6 +554,8 @@ export type ConfigType =
|
|||||||
| 'game-versions';
|
| 'game-versions';
|
||||||
|
|
||||||
export interface PokemonPayload {
|
export interface PokemonPayload {
|
||||||
|
dataId?: number | null;
|
||||||
|
dataIdentifier?: string;
|
||||||
displayId: number;
|
displayId: number;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -836,6 +900,13 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
languages: () => getJson<Language[]>('/api/languages'),
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
|
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
||||||
|
getJson<ProjectUpdates>(
|
||||||
|
`/api/project-updates${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit
|
||||||
|
})}`
|
||||||
|
),
|
||||||
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
||||||
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
|
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
|
||||||
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
|
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
|
||||||
@@ -853,6 +924,10 @@ export const api = {
|
|||||||
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
|
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
|
||||||
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
|
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
|
||||||
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
|
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),
|
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||||
verifyEmail: (token: string) =>
|
verifyEmail: (token: string) =>
|
||||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||||
|
|||||||
@@ -835,6 +835,59 @@ button:disabled,
|
|||||||
gap: 12px;
|
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 {
|
.pokemon-edit-form {
|
||||||
height: clamp(420px, calc(100dvh - 188px), 640px);
|
height: clamp(420px, calc(100dvh - 188px), 640px);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -4852,6 +4905,332 @@ button:disabled,
|
|||||||
align-self: end;
|
align-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-project-updates__panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo-label,
|
||||||
|
.home-project-updates__updated {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo a {
|
||||||
|
min-width: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-weight: 950;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__repo a:hover {
|
||||||
|
color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__updated {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__skeleton,
|
||||||
|
.home-project-updates__content,
|
||||||
|
.home-project-updates__group,
|
||||||
|
.home-project-updates__commit {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__skeleton,
|
||||||
|
.home-project-updates__content {
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__skeleton {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__group {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__group h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 950;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__list {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item {
|
||||||
|
min-height: 78px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__commit {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__title strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.28;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__sha {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__link {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-panel h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--pokemon-yellow);
|
||||||
|
box-shadow: 0 3px 0 var(--line-strong);
|
||||||
|
color: #172036;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy span,
|
||||||
|
.project-updates-repo__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy a {
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-weight: 950;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo__copy a:hover {
|
||||||
|
color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__item {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__item:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__row,
|
||||||
|
.project-updates-list__item:not(.project-updates-list__item--commit) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__title strong {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.28;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__sha {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-message {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-message span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-message pre {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-more-skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-page {
|
.auth-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
@@ -5898,6 +6277,21 @@ button:disabled,
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-project-updates__updated {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-repo,
|
||||||
|
.project-updates-list__row,
|
||||||
|
.project-updates-list__item:not(.project-updates-list__item--commit) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.appearance-row__main {
|
.appearance-row__main {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
@@ -5973,6 +6367,36 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item,
|
||||||
|
.home-project-updates__title {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__item {
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__title {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-project-updates__link {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-panel {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__title {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-updates-list__actions .ui-button,
|
||||||
|
.project-updates-list__item > .ui-button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.home-dex__screen {
|
.home-dex__screen {
|
||||||
min-height: 420px;
|
min-height: 420px;
|
||||||
margin: 12px;
|
margin: 12px;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
iconRecipe,
|
iconRecipe,
|
||||||
iconSave,
|
iconSave,
|
||||||
iconTranslate,
|
iconTranslate,
|
||||||
|
iconUpload,
|
||||||
type AppIcon
|
type AppIcon
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||||
@@ -36,6 +37,9 @@ import {
|
|||||||
type AuthUser,
|
type AuthUser,
|
||||||
type AdminUser,
|
type AdminUser,
|
||||||
type ConfigType,
|
type ConfigType,
|
||||||
|
type DataToolScope,
|
||||||
|
type DataToolsBundle,
|
||||||
|
type DataToolsSummary,
|
||||||
type DailyChecklistItem,
|
type DailyChecklistItem,
|
||||||
type GameVersion,
|
type GameVersion,
|
||||||
type Habitat,
|
type Habitat,
|
||||||
@@ -65,6 +69,7 @@ type AdminTab =
|
|||||||
| 'permissions'
|
| 'permissions'
|
||||||
| 'rateLimits'
|
| 'rateLimits'
|
||||||
| 'aiModeration'
|
| 'aiModeration'
|
||||||
|
| 'dataTools'
|
||||||
| 'config'
|
| 'config'
|
||||||
| 'languages'
|
| 'languages'
|
||||||
| 'wordings'
|
| 'wordings'
|
||||||
@@ -97,6 +102,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
|
|||||||
'upload',
|
'upload',
|
||||||
'fetch'
|
'fetch'
|
||||||
];
|
];
|
||||||
|
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
|
||||||
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
|
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
|
||||||
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
|
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
|
||||||
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
|
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
|
||||||
@@ -113,6 +119,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
|||||||
permissions: iconKey,
|
permissions: iconKey,
|
||||||
rateLimits: iconAdmin,
|
rateLimits: iconAdmin,
|
||||||
aiModeration: iconAdmin,
|
aiModeration: iconAdmin,
|
||||||
|
dataTools: iconAdmin,
|
||||||
config: iconAdmin,
|
config: iconAdmin,
|
||||||
languages: iconTranslate,
|
languages: iconTranslate,
|
||||||
wordings: iconTranslate,
|
wordings: iconTranslate,
|
||||||
@@ -140,7 +147,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
|||||||
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
|
{ 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: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
|
||||||
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.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 wordingRows = ref<SystemWording[]>([]);
|
||||||
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
||||||
const rateLimitSettings = ref<RateLimitSettings | null>(null);
|
const rateLimitSettings = ref<RateLimitSettings | null>(null);
|
||||||
|
const dataToolsSummary = ref<DataToolsSummary | null>(null);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const contentLoading = ref(false);
|
const contentLoading = ref(false);
|
||||||
@@ -251,10 +260,16 @@ const userRoleModalOpen = ref(false);
|
|||||||
const roleModalOpen = ref(false);
|
const roleModalOpen = ref(false);
|
||||||
const rolePermissionsModalOpen = ref(false);
|
const rolePermissionsModalOpen = ref(false);
|
||||||
const permissionModalOpen = ref(false);
|
const permissionModalOpen = ref(false);
|
||||||
|
const dataToolImportModalOpen = ref(false);
|
||||||
|
const dataToolWipeModalOpen = ref(false);
|
||||||
const wordingLocale = ref(getCurrentLocale());
|
const wordingLocale = ref(getCurrentLocale());
|
||||||
const wordingModule = ref('');
|
const wordingModule = ref('');
|
||||||
const wordingSurface = ref<SystemWordingSurface | ''>('');
|
const wordingSurface = ref<SystemWordingSurface | ''>('');
|
||||||
const wordingMissingOnly = ref(false);
|
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 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 })));
|
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: 'upload', label: t('pages.admin.rateLimitUpload') },
|
||||||
{ value: 'fetch', label: t('pages.admin.rateLimitFetch') }
|
{ 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(() =>
|
const filteredWordingRows = computed(() =>
|
||||||
wordingRows.value.filter((item) => {
|
wordingRows.value.filter((item) => {
|
||||||
if (wordingModule.value && item.module !== wordingModule.value) return false;
|
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);
|
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) {
|
function dragSortLabel(name: string) {
|
||||||
return t('pages.admin.dragSort', { name });
|
return t('pages.admin.dragSort', { name });
|
||||||
}
|
}
|
||||||
@@ -928,6 +990,10 @@ async function loadRateLimitSettings() {
|
|||||||
resetRateLimitForm(rateLimitSettings.value);
|
resetRateLimitForm(rateLimitSettings.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadDataToolsSummary() {
|
||||||
|
dataToolsSummary.value = await api.dataToolsSummary();
|
||||||
|
}
|
||||||
|
|
||||||
async function reloadWordings() {
|
async function reloadWordings() {
|
||||||
await run(loadWordings);
|
await run(loadWordings);
|
||||||
}
|
}
|
||||||
@@ -1051,6 +1117,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
|||||||
if (activeTab.value === 'languages') await loadLanguages();
|
if (activeTab.value === 'languages') await loadLanguages();
|
||||||
if (activeTab.value === 'wordings') await loadWordings();
|
if (activeTab.value === 'wordings') await loadWordings();
|
||||||
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
|
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
|
||||||
|
if (activeTab.value === 'dataTools') await loadDataToolsSummary();
|
||||||
if (activeTab.value === 'checklist') await loadChecklist();
|
if (activeTab.value === 'checklist') await loadChecklist();
|
||||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||||
if (activeTab.value === 'items') await loadItems();
|
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) {
|
async function removeRole(id: number) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await api.deleteRole(id);
|
await api.deleteRole(id);
|
||||||
@@ -1425,6 +1603,78 @@ onMounted(() => {
|
|||||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
</section>
|
</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">
|
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
||||||
<div class="detail-section__header">
|
<div class="detail-section__header">
|
||||||
<h2>{{ t('pages.admin.config') }}</h2>
|
<h2>{{ t('pages.admin.config') }}</h2>
|
||||||
@@ -1802,6 +2052,44 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<form id="admin-user-roles-form" class="modal-edit-form" @submit.prevent="saveUserRoles">
|
||||||
<div v-if="editingUser" class="access-modal-heading">
|
<div v-if="editingUser" class="access-modal-heading">
|
||||||
|
|||||||
@@ -1,25 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import PokeBallMark from '../components/PokeBallMark.vue';
|
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
import StatusBadge from '../components/StatusBadge.vue';
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
import {
|
import {
|
||||||
iconAction,
|
iconAction,
|
||||||
iconAutomation,
|
iconAutomation,
|
||||||
|
iconChevronRight,
|
||||||
iconChecklist,
|
iconChecklist,
|
||||||
iconClothes,
|
iconClothes,
|
||||||
iconDish,
|
iconDish,
|
||||||
iconDreamIsland,
|
iconDreamIsland,
|
||||||
iconEvent,
|
iconEvent,
|
||||||
|
iconExternal,
|
||||||
|
iconGitCommit,
|
||||||
iconHabitat,
|
iconHabitat,
|
||||||
iconItem,
|
iconItem,
|
||||||
iconLife,
|
iconLife,
|
||||||
iconPokemon,
|
iconPokemon,
|
||||||
iconRecipe
|
iconRecipe
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
|
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
|
const projectCommitPageSize = 5;
|
||||||
|
const projectUpdates = ref<ProjectUpdates | null>(null);
|
||||||
|
const projectUpdatesLoading = ref(true);
|
||||||
|
const projectCommits = ref<ProjectUpdateCommit[]>([]);
|
||||||
|
|
||||||
const primarySections = computed(() => [
|
const primarySections = computed(() => [
|
||||||
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
|
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
|
||||||
@@ -42,6 +51,17 @@ const futureSections = computed(() => [
|
|||||||
{ key: 'clothes', to: '/clothes', icon: iconClothes }
|
{ key: 'clothes', to: '/clothes', icon: iconClothes }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const latestReleases = computed(() => projectUpdates.value?.releases.slice(0, 3) ?? []);
|
||||||
|
const showProjectUpdates = computed(
|
||||||
|
() => projectUpdatesLoading.value || projectCommits.value.length > 0 || latestReleases.value.length > 0
|
||||||
|
);
|
||||||
|
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
||||||
|
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadProjectUpdates();
|
||||||
|
});
|
||||||
|
|
||||||
function sectionTitleKey(key: string) {
|
function sectionTitleKey(key: string) {
|
||||||
return `pages.home.sections.${key}.title`;
|
return `pages.home.sections.${key}.title`;
|
||||||
}
|
}
|
||||||
@@ -49,6 +69,34 @@ function sectionTitleKey(key: string) {
|
|||||||
function sectionDescriptionKey(key: string) {
|
function sectionDescriptionKey(key: string) {
|
||||||
return `pages.home.sections.${key}.description`;
|
return `pages.home.sections.${key}.description`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadProjectUpdates(): Promise<void> {
|
||||||
|
projectUpdatesLoading.value = true;
|
||||||
|
try {
|
||||||
|
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||||
|
projectUpdates.value = updates;
|
||||||
|
projectCommits.value = updates.commits.items;
|
||||||
|
} catch {
|
||||||
|
projectUpdates.value = null;
|
||||||
|
projectCommits.value = [];
|
||||||
|
} finally {
|
||||||
|
projectUpdatesLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -145,6 +193,84 @@ function sectionDescriptionKey(key: string) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-if="showProjectUpdates" class="home-section home-project-updates" aria-labelledby="home-project-updates-title">
|
||||||
|
<div class="home-section__header">
|
||||||
|
<span class="page-kicker">{{ t('pages.home.projectUpdatesKicker') }}</span>
|
||||||
|
<h2 id="home-project-updates-title">{{ t('pages.home.projectUpdatesTitle') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="home-project-updates__panel">
|
||||||
|
<div v-if="projectUpdates" class="home-project-updates__repo">
|
||||||
|
<span class="home-project-updates__repo-label">{{ t('pages.home.projectUpdatesRepo') }}</span>
|
||||||
|
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconGitCommit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ projectUpdates.repository.fullName }}
|
||||||
|
</a>
|
||||||
|
<span v-if="repositoryUpdatedAt" class="home-project-updates__updated">
|
||||||
|
{{ t('pages.home.projectUpdatesUpdatedAt', { date: repositoryUpdatedAt }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectUpdatesLoading" class="home-project-updates__skeleton">
|
||||||
|
<Skeleton width="42%" />
|
||||||
|
<Skeleton width="76%" />
|
||||||
|
<Skeleton width="64%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="projectUpdates" class="home-project-updates__content">
|
||||||
|
<div v-if="latestReleases.length" class="home-project-updates__group">
|
||||||
|
<h3>{{ t('pages.home.projectUpdatesReleases') }}</h3>
|
||||||
|
<ol class="home-project-updates__list">
|
||||||
|
<li v-for="release in latestReleases" :key="release.tagName" class="home-project-updates__item">
|
||||||
|
<div class="home-project-updates__commit">
|
||||||
|
<div class="home-project-updates__title">
|
||||||
|
<span class="home-project-updates__sha">{{ release.tagName }}</span>
|
||||||
|
<strong>{{ release.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="release.publishedAt" class="home-project-updates__meta">
|
||||||
|
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="ui-button ui-button--ghost home-project-updates__link" :href="release.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.home.projectUpdatesViewRelease') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectCommits.length" class="home-project-updates__group">
|
||||||
|
<h3>{{ t('pages.home.projectUpdatesCommits') }}</h3>
|
||||||
|
<ol class="home-project-updates__list">
|
||||||
|
<li v-for="commit in projectCommits" :key="commit.sha" class="home-project-updates__item">
|
||||||
|
<div class="home-project-updates__commit">
|
||||||
|
<div class="home-project-updates__title">
|
||||||
|
<span class="home-project-updates__sha">{{ commit.shortSha }}</span>
|
||||||
|
<strong>{{ commit.title }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="home-project-updates__meta">
|
||||||
|
<span>{{ commit.authorName }}</span>
|
||||||
|
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="ui-button ui-button--ghost home-project-updates__link" :href="commit.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.home.projectUpdatesViewCommit') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div v-if="showProjectUpdatesViewAll" class="home-project-updates__actions">
|
||||||
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/project-updates">
|
||||||
|
<Icon :icon="iconChevronRight" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.home.projectUpdatesViewAll') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="home-section" aria-labelledby="home-future-title">
|
<section class="home-section" aria-labelledby="home-future-title">
|
||||||
<div class="home-section__header">
|
<div class="home-section__header">
|
||||||
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>
|
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ function defaultPokemonStats(): PokemonStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pokemonForm = ref({
|
const pokemonForm = ref({
|
||||||
|
dataId: null as number | null,
|
||||||
|
dataIdentifier: '',
|
||||||
id: '',
|
id: '',
|
||||||
isEventItem: false,
|
isEventItem: false,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -257,8 +259,22 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
|
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 = {
|
||||||
...pokemonForm.value,
|
...pokemonForm.value,
|
||||||
|
dataId: fetchedPokemon.id,
|
||||||
|
dataIdentifier: fetchedPokemon.identifier,
|
||||||
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
|
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
|
||||||
name: fetchedPokemon.name,
|
name: fetchedPokemon.name,
|
||||||
genus: fetchedPokemon.genus,
|
genus: fetchedPokemon.genus,
|
||||||
@@ -295,6 +311,8 @@ async function loadEditor() {
|
|||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
const pokemon = await api.pokemonDetail(routeId.value);
|
const pokemon = await api.pokemonDetail(routeId.value);
|
||||||
pokemonForm.value = {
|
pokemonForm.value = {
|
||||||
|
dataId: pokemon.dataId ?? null,
|
||||||
|
dataIdentifier: pokemon.dataIdentifier ?? '',
|
||||||
id: String(pokemon.displayId),
|
id: String(pokemon.displayId),
|
||||||
isEventItem: pokemon.isEventItem,
|
isEventItem: pokemon.isEventItem,
|
||||||
name: pokemon.baseName ?? pokemon.name,
|
name: pokemon.baseName ?? pokemon.name,
|
||||||
@@ -678,6 +696,8 @@ async function savePokemon() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: PokemonPayload = {
|
const payload: PokemonPayload = {
|
||||||
|
dataId: pokemonForm.value.dataId,
|
||||||
|
dataIdentifier: pokemonForm.value.dataIdentifier,
|
||||||
displayId: pokemonIdForSave(),
|
displayId: pokemonIdForSave(),
|
||||||
isEventItem: pokemonForm.value.isEventItem,
|
isEventItem: pokemonForm.value.isEventItem,
|
||||||
name: pokemonNameForSave(),
|
name: pokemonNameForSave(),
|
||||||
|
|||||||
284
frontend/src/views/ProjectUpdatesView.vue
Normal file
284
frontend/src/views/ProjectUpdatesView.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import {
|
||||||
|
iconChevronDown,
|
||||||
|
iconChevronUp,
|
||||||
|
iconExternal,
|
||||||
|
iconGitCommit,
|
||||||
|
iconWarning
|
||||||
|
} from '../icons';
|
||||||
|
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
|
||||||
|
|
||||||
|
const { t, locale } = useI18n();
|
||||||
|
const projectCommitPageSize = 10;
|
||||||
|
const projectUpdates = ref<ProjectUpdates | null>(null);
|
||||||
|
const projectCommits = ref<ProjectUpdateCommit[]>([]);
|
||||||
|
const projectCommitCursor = ref<string | null>(null);
|
||||||
|
const projectHasMoreCommits = ref(false);
|
||||||
|
const loading = ref(true);
|
||||||
|
const loadingMore = ref(false);
|
||||||
|
const loadError = ref(false);
|
||||||
|
const loadMorePaused = ref(false);
|
||||||
|
const loadMoreSentinel = ref<HTMLElement | null>(null);
|
||||||
|
const expandedCommitShas = ref<Set<string>>(new Set());
|
||||||
|
let projectUpdatesObserver: IntersectionObserver | null = null;
|
||||||
|
|
||||||
|
const releases = computed(() => projectUpdates.value?.releases ?? []);
|
||||||
|
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadProjectUpdates();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disconnectProjectUpdatesObserver();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadProjectUpdates(): Promise<void> {
|
||||||
|
loading.value = true;
|
||||||
|
loadingMore.value = false;
|
||||||
|
loadError.value = false;
|
||||||
|
loadMorePaused.value = false;
|
||||||
|
projectCommitCursor.value = null;
|
||||||
|
projectHasMoreCommits.value = false;
|
||||||
|
expandedCommitShas.value = new Set();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||||
|
projectUpdates.value = updates;
|
||||||
|
projectCommits.value = updates.commits.items;
|
||||||
|
projectCommitCursor.value = updates.commits.nextCursor;
|
||||||
|
projectHasMoreCommits.value = updates.commits.hasMore;
|
||||||
|
} catch {
|
||||||
|
projectUpdates.value = null;
|
||||||
|
projectCommits.value = [];
|
||||||
|
loadError.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMoreProjectUpdates(): Promise<void> {
|
||||||
|
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = projectCommitCursor.value;
|
||||||
|
if (!cursor) {
|
||||||
|
projectHasMoreCommits.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadingMore.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates = await api.projectUpdates({
|
||||||
|
cursor,
|
||||||
|
limit: projectCommitPageSize
|
||||||
|
});
|
||||||
|
projectUpdates.value = updates;
|
||||||
|
const existingShas = new Set(projectCommits.value.map((commit) => commit.sha));
|
||||||
|
projectCommits.value = [...projectCommits.value, ...updates.commits.items.filter((commit) => !existingShas.has(commit.sha))];
|
||||||
|
projectCommitCursor.value = updates.commits.nextCursor;
|
||||||
|
projectHasMoreCommits.value = updates.commits.hasMore;
|
||||||
|
} catch {
|
||||||
|
loadMorePaused.value = true;
|
||||||
|
} finally {
|
||||||
|
loadingMore.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryLoadMore(): void {
|
||||||
|
loadMorePaused.value = false;
|
||||||
|
void loadMoreProjectUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCommitMessage(sha: string): void {
|
||||||
|
const nextExpanded = new Set(expandedCommitShas.value);
|
||||||
|
if (nextExpanded.has(sha)) {
|
||||||
|
nextExpanded.delete(sha);
|
||||||
|
} else {
|
||||||
|
nextExpanded.add(sha);
|
||||||
|
}
|
||||||
|
expandedCommitShas.value = nextExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommitExpanded(sha: string): boolean {
|
||||||
|
return expandedCommitShas.value.has(sha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectProjectUpdatesObserver(): void {
|
||||||
|
projectUpdatesObserver?.disconnect();
|
||||||
|
projectUpdatesObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeProjectUpdatesLoadMore(): void {
|
||||||
|
disconnectProjectUpdatesObserver();
|
||||||
|
|
||||||
|
if (loading.value || loadingMore.value || loadMorePaused.value || !projectHasMoreCommits.value || !loadMoreSentinel.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
void loadMoreProjectUpdates();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
projectUpdatesObserver = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((entry) => entry.isIntersecting)) {
|
||||||
|
void loadMoreProjectUpdates();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '360px 0px' }
|
||||||
|
);
|
||||||
|
projectUpdatesObserver.observe(loadMoreSentinel.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat(locale.value, {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([loadMoreSentinel, projectHasMoreCommits, loading, loadingMore, loadMorePaused], observeProjectUpdatesLoadMore, {
|
||||||
|
flush: 'post'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-stack project-updates-page">
|
||||||
|
<PageHeader :title="t('pages.projectUpdates.title')" :subtitle="t('pages.projectUpdates.subtitle')">
|
||||||
|
<template #kicker>{{ t('pages.projectUpdates.kicker') }}</template>
|
||||||
|
<template v-if="projectUpdates" #actions>
|
||||||
|
<a class="ui-button ui-button--ghost" :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.openRepository') }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<section v-if="loading" class="project-updates-panel" aria-busy="true" :aria-label="t('pages.projectUpdates.loading')">
|
||||||
|
<Skeleton width="40%" height="22px" />
|
||||||
|
<Skeleton width="76%" />
|
||||||
|
<Skeleton width="64%" />
|
||||||
|
<Skeleton variant="box" height="78px" />
|
||||||
|
<Skeleton variant="box" height="78px" />
|
||||||
|
<Skeleton variant="box" height="78px" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="loadError" class="project-updates-panel">
|
||||||
|
<p class="status">{{ t('errors.loadFailed') }}</p>
|
||||||
|
<button class="ui-button ui-button--ghost" type="button" @click="loadProjectUpdates">
|
||||||
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.retry') }}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-else-if="projectUpdates">
|
||||||
|
<section class="project-updates-panel">
|
||||||
|
<div class="project-updates-repo">
|
||||||
|
<span class="project-updates-repo__icon" aria-hidden="true">
|
||||||
|
<Icon :icon="iconGitCommit" class="ui-icon" />
|
||||||
|
</span>
|
||||||
|
<div class="project-updates-repo__copy">
|
||||||
|
<span>{{ t('pages.projectUpdates.sourceRepository') }}</span>
|
||||||
|
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||||
|
{{ projectUpdates.repository.fullName }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span v-if="repositoryUpdatedAt" class="project-updates-repo__meta">
|
||||||
|
{{ t('pages.projectUpdates.updatedAt', { date: repositoryUpdatedAt }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="releases.length" class="project-updates-panel">
|
||||||
|
<h2>{{ t('pages.projectUpdates.releases') }}</h2>
|
||||||
|
<ol class="project-updates-list">
|
||||||
|
<li v-for="release in releases" :key="release.tagName" class="project-updates-list__item">
|
||||||
|
<div class="project-updates-list__main">
|
||||||
|
<div class="project-updates-list__title">
|
||||||
|
<span class="project-updates-list__sha">{{ release.tagName }}</span>
|
||||||
|
<strong>{{ release.name }}</strong>
|
||||||
|
</div>
|
||||||
|
<div v-if="release.publishedAt" class="project-updates-list__meta">
|
||||||
|
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a class="ui-button ui-button--ghost ui-button--small" :href="release.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.viewRelease') }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="project-updates-panel">
|
||||||
|
<h2>{{ t('pages.projectUpdates.commits') }}</h2>
|
||||||
|
<ol v-if="projectCommits.length" class="project-updates-list">
|
||||||
|
<li v-for="commit in projectCommits" :key="commit.sha" class="project-updates-list__item project-updates-list__item--commit">
|
||||||
|
<div class="project-updates-list__row">
|
||||||
|
<div class="project-updates-list__main">
|
||||||
|
<div class="project-updates-list__title">
|
||||||
|
<span class="project-updates-list__sha">{{ commit.shortSha }}</span>
|
||||||
|
<strong>{{ commit.title }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="project-updates-list__meta">
|
||||||
|
<span>{{ commit.authorName }}</span>
|
||||||
|
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="project-updates-list__actions">
|
||||||
|
<button
|
||||||
|
class="ui-button ui-button--ghost ui-button--small"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="isCommitExpanded(commit.sha)"
|
||||||
|
@click="toggleCommitMessage(commit.sha)"
|
||||||
|
>
|
||||||
|
<Icon :icon="isCommitExpanded(commit.sha) ? iconChevronUp : iconChevronDown" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ isCommitExpanded(commit.sha) ? t('pages.projectUpdates.collapseMessage') : t('pages.projectUpdates.expandMessage') }}
|
||||||
|
</button>
|
||||||
|
<a class="ui-button ui-button--ghost ui-button--small" :href="commit.url" target="_blank" rel="noreferrer">
|
||||||
|
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.viewCommit') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isCommitExpanded(commit.sha)" class="project-updates-message">
|
||||||
|
<span>{{ t('pages.projectUpdates.commitMessage') }}</span>
|
||||||
|
<pre>{{ commit.message }}</pre>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p v-else class="meta-line">{{ t('pages.projectUpdates.empty') }}</p>
|
||||||
|
|
||||||
|
<div v-if="loadingMore" class="project-updates-more-skeleton">
|
||||||
|
<Skeleton width="82%" />
|
||||||
|
<Skeleton width="58%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="projectHasMoreCommits" ref="loadMoreSentinel" class="project-updates-sentinel" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<div v-if="loadMorePaused && projectHasMoreCommits" class="project-updates-actions">
|
||||||
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="retryLoadMore">
|
||||||
|
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.projectUpdates.retry') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -157,6 +157,15 @@ export const systemWordingMessages = {
|
|||||||
wikiTitle: 'Browse game records',
|
wikiTitle: 'Browse game records',
|
||||||
communityKicker: 'Daily & Community',
|
communityKicker: 'Daily & Community',
|
||||||
communityTitle: 'Follow daily tasks and community updates',
|
communityTitle: 'Follow daily tasks and community updates',
|
||||||
|
projectUpdatesKicker: 'Project Updates',
|
||||||
|
projectUpdatesTitle: 'Latest site changes',
|
||||||
|
projectUpdatesRepo: 'Source repository',
|
||||||
|
projectUpdatesUpdatedAt: 'Updated {date}',
|
||||||
|
projectUpdatesCommits: 'Recent commits',
|
||||||
|
projectUpdatesReleases: 'Releases',
|
||||||
|
projectUpdatesViewCommit: 'View commit',
|
||||||
|
projectUpdatesViewRelease: 'View release',
|
||||||
|
projectUpdatesViewAll: 'View all',
|
||||||
futureKicker: 'More Sections',
|
futureKicker: 'More Sections',
|
||||||
futureTitle: 'Planned wiki areas',
|
futureTitle: 'Planned wiki areas',
|
||||||
sections: {
|
sections: {
|
||||||
@@ -210,6 +219,24 @@ export const systemWordingMessages = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
projectUpdates: {
|
||||||
|
kicker: 'Project Updates',
|
||||||
|
title: 'Project Updates',
|
||||||
|
subtitle: 'Follow public site changes from the Pokopia Wiki source repository.',
|
||||||
|
sourceRepository: 'Source repository',
|
||||||
|
updatedAt: 'Updated {date}',
|
||||||
|
openRepository: 'Open repository',
|
||||||
|
commits: 'Commits',
|
||||||
|
releases: 'Releases',
|
||||||
|
viewCommit: 'View commit',
|
||||||
|
viewRelease: 'View release',
|
||||||
|
expandMessage: 'Expand',
|
||||||
|
collapseMessage: 'Collapse',
|
||||||
|
commitMessage: 'Commit message',
|
||||||
|
loading: 'Loading project updates',
|
||||||
|
retry: 'Retry',
|
||||||
|
empty: 'No commits yet'
|
||||||
|
},
|
||||||
legal: {
|
legal: {
|
||||||
lastUpdated: 'Last updated: May 3, 2026',
|
lastUpdated: 'Last updated: May 3, 2026',
|
||||||
sourceLinks: 'Source and reference links',
|
sourceLinks: 'Source and reference links',
|
||||||
@@ -805,6 +832,30 @@ export const systemWordingMessages = {
|
|||||||
itemList: 'Item list',
|
itemList: 'Item list',
|
||||||
recipeList: 'Recipe list',
|
recipeList: 'Recipe list',
|
||||||
habitatList: 'Habitat 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',
|
languages: 'Languages',
|
||||||
newConfig: 'New {name}',
|
newConfig: 'New {name}',
|
||||||
editConfig: 'Edit {name}',
|
editConfig: 'Edit {name}',
|
||||||
@@ -1027,6 +1078,10 @@ export const systemWordingMessages = {
|
|||||||
pokemonIdentifierRequired: 'Pokemon identifier is required',
|
pokemonIdentifierRequired: 'Pokemon identifier is required',
|
||||||
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
||||||
pokemonDataNotFound: 'Pokemon data was not found',
|
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',
|
pokemonImagePathInvalid: 'Pokemon image path is invalid',
|
||||||
imagePathInvalid: 'Image path is invalid',
|
imagePathInvalid: 'Image path is invalid',
|
||||||
imageUploadRequired: 'Please select an image',
|
imageUploadRequired: 'Please select an image',
|
||||||
@@ -1263,6 +1318,15 @@ export const systemWordingMessages = {
|
|||||||
wikiTitle: '浏览游戏资料',
|
wikiTitle: '浏览游戏资料',
|
||||||
communityKicker: '每日与社区',
|
communityKicker: '每日与社区',
|
||||||
communityTitle: '查看每日任务和社区更新',
|
communityTitle: '查看每日任务和社区更新',
|
||||||
|
projectUpdatesKicker: '项目更新',
|
||||||
|
projectUpdatesTitle: '最近站点改动',
|
||||||
|
projectUpdatesRepo: '源码仓库',
|
||||||
|
projectUpdatesUpdatedAt: '更新于 {date}',
|
||||||
|
projectUpdatesCommits: '最近提交',
|
||||||
|
projectUpdatesReleases: '发布版本',
|
||||||
|
projectUpdatesViewCommit: '查看提交',
|
||||||
|
projectUpdatesViewRelease: '查看发布',
|
||||||
|
projectUpdatesViewAll: '查看全部',
|
||||||
futureKicker: '更多分区',
|
futureKicker: '更多分区',
|
||||||
futureTitle: '规划中的 Wiki 区域',
|
futureTitle: '规划中的 Wiki 区域',
|
||||||
sections: {
|
sections: {
|
||||||
@@ -1316,6 +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: {
|
legal: {
|
||||||
lastUpdated: '最后更新:2026年5月3日',
|
lastUpdated: '最后更新:2026年5月3日',
|
||||||
sourceLinks: '来源与参考链接',
|
sourceLinks: '来源与参考链接',
|
||||||
@@ -1891,6 +1973,30 @@ export const systemWordingMessages = {
|
|||||||
itemList: '物品列表',
|
itemList: '物品列表',
|
||||||
recipeList: '材料单列表',
|
recipeList: '材料单列表',
|
||||||
habitatList: '栖息地列表',
|
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: '语言',
|
languages: '语言',
|
||||||
newConfig: '新增{name}',
|
newConfig: '新增{name}',
|
||||||
editConfig: '编辑{name}',
|
editConfig: '编辑{name}',
|
||||||
@@ -2113,6 +2219,10 @@ export const systemWordingMessages = {
|
|||||||
pokemonIdentifierRequired: '请输入 Pokemon 标识',
|
pokemonIdentifierRequired: '请输入 Pokemon 标识',
|
||||||
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
||||||
pokemonDataNotFound: '未找到 Pokemon 数据',
|
pokemonDataNotFound: '未找到 Pokemon 数据',
|
||||||
|
pokemonDataIdMismatch: 'Pokemon 数据 ID 与当前 Pokemon 不一致',
|
||||||
|
dataToolScopeRequired: '请至少选择一个数据范围',
|
||||||
|
dataToolScopeInvalid: '数据范围不合法',
|
||||||
|
dataToolBundleInvalid: '数据包不合法',
|
||||||
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
||||||
imagePathInvalid: '图片路径不合法',
|
imagePathInvalid: '图片路径不合法',
|
||||||
imageUploadRequired: '请选择图片',
|
imageUploadRequired: '请选择图片',
|
||||||
|
|||||||
Reference in New Issue
Block a user