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` 角色。
|
||||
- 管理 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 是账号功能,用于让已注册用户邀请新用户加入 Pokopia Wiki。
|
||||
@@ -407,7 +443,8 @@
|
||||
|
||||
Pokemon 可配置:
|
||||
|
||||
- 内部 ID:`id`,系统唯一,用于路由、外键和实体关联;普通 Pokemon 新建时优先与展示 ID 一致,活动 Pokemon 由系统分配唯一内部 ID
|
||||
- 内部 ID:`id`,系统唯一,用于路由、外键和实体关联;从 CSV Fetch 创建的普通 Pokemon 使用官方 data Pokemon ID 作为内部 ID,活动 Pokemon 和未关联官方 data 的自定义 Pokemon 由系统分配唯一内部 ID
|
||||
- 官方 data 身份:`data_id` 和 `data_identifier`,可为空;用于记录该 Pokemon 对应的 CSV 官方 Pokemon ID 与 identifier,不作为用户可编辑展示 ID
|
||||
- 展示 ID:`display_id`,详情页、列表卡片和选择器中显示为 `#ID`
|
||||
- 是否为活动物品:`is_event_item`
|
||||
- 名称
|
||||
@@ -432,7 +469,7 @@ Pokemon 可配置:
|
||||
- 翻译
|
||||
- 排序
|
||||
|
||||
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。
|
||||
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon,`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。
|
||||
|
||||
Pokemon 编辑表单使用标签页组织字段:
|
||||
|
||||
@@ -442,8 +479,9 @@ Pokemon 编辑表单使用标签页组织字段:
|
||||
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
||||
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
||||
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
||||
- Fetch 只填入 CSV 可提供的字段:官方 data ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
||||
- Fetch 只填入 CSV 可提供的字段:官方 data ID、官方 data identifier、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
||||
- Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID,只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。
|
||||
- Fetch 后保存普通 Pokemon 时,官方 data ID 作为内部路由 ID;展示 ID 只保存到 `display_id`。
|
||||
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。
|
||||
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
||||
@@ -746,6 +784,24 @@ API 暴露边界:
|
||||
- Pokemon 相关名称、图片、标志、角色和游戏素材归其各自权利人所有。
|
||||
- 法律页面和页脚文案必须通过系统级文案 catalog 管理,并支持现有语言回退机制。
|
||||
|
||||
## 项目更新展示
|
||||
|
||||
- Home 首页可展示 Pokopia Wiki 站点项目的公开更新信息,用于让访客了解站点代码与发布进展。
|
||||
- 完整项目更新页路径为 `/project-updates`,由 Home 首页项目更新预览区的 View All 入口进入。
|
||||
- 更新信息来源为公开 Gitea 仓库 `https://git.tootaio.com/Kingsmai/pokopiawiki.tootaio.com`。
|
||||
- 前端不得直接读取 Gitea API;后端通过 `GET /api/project-updates` 代理并净化公开仓库数据。
|
||||
- 项目更新 API 只返回展示所需字段:
|
||||
- 仓库:`name`、`fullName`、公开仓库 `url`、`defaultBranch`、`updatedAt`。
|
||||
- 最近提交分页:`items`、`nextCursor`、`hasMore`;每条提交只包含 `sha`、`shortSha`、提交标题 `title`、完整提交消息 `message`、`createdAt`、不含邮箱的 `authorName`、公开提交 `url`。
|
||||
- 发布版本:`tagName`、`name`、`publishedAt`、公开发布 `url`。
|
||||
- 最近提交支持 `limit` 和不透明 `cursor` 增量读取;前端不得依赖 Gitea 的 `page` / `limit` 实现细节。
|
||||
- 项目更新 API 不返回 Gitea token、用户邮箱、内部 API URL、内网地址、文件列表、提交统计、Actions 日志、构建日志或调试字段。
|
||||
- Home 首页默认展示最近提交预览;用户可通过 View All 进入 `/project-updates` 完整页面。
|
||||
- `/project-updates` 按 Life Post 相同的增量方式继续显示更多提交。
|
||||
- `/project-updates` 的每条提交默认折叠,仅展示标题、短 SHA、作者和时间;用户可展开单条提交查看完整 Commit Message,并可再次收起。
|
||||
- 若仓库后续提供 Release,可展示发布版本。没有 Release 时不展示空发布区块。
|
||||
- Gitea 读取失败时不得在前台展示内部错误或调试信息。
|
||||
|
||||
## 前端交互与 UI
|
||||
|
||||
- UI 风格以 `DesignGuidelines.html` 为准。
|
||||
@@ -753,7 +809,7 @@ API 暴露边界:
|
||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
||||
- 配置:System config。
|
||||
- 内容:Daily CheckList、Pokemon、物品、材料单和栖息地的维护、排序或删除入口。
|
||||
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。
|
||||
- 本地化:Languages、System wordings。
|
||||
- 访问权限:Users、Roles、Permissions、Rate limits。
|
||||
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||
@@ -792,6 +848,7 @@ API 暴露边界:
|
||||
- `/recipes`
|
||||
- `/checklist`
|
||||
- `/life`
|
||||
- `/project-updates`
|
||||
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口;实体详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap。
|
||||
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后,用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
|
||||
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
||||
@@ -806,6 +863,7 @@ API 暴露边界:
|
||||
- `GET /api/languages`
|
||||
- `GET /api/system-wordings`
|
||||
- `GET /api/options`
|
||||
- `GET /api/project-updates`:读取站点项目公开更新信息;支持 `cursor` / `limit` 分页读取最近提交;仅返回净化后的仓库、最近提交和发布版本展示字段。
|
||||
- `GET /api/daily-checklist`
|
||||
- `GET /api/pokemon`
|
||||
- `GET /api/pokemon/:id`
|
||||
@@ -848,6 +906,10 @@ API 暴露边界:
|
||||
- `POST /api/admin/permissions`:需要 `admin.permissions.create`
|
||||
- `PUT /api/admin/permissions/:id`:需要 `admin.permissions.update`
|
||||
- `DELETE /api/admin/permissions/:id`:需要 `admin.permissions.delete`
|
||||
- `GET /api/admin/data-tools/summary`:需要 `admin.data.export` 或 `admin.data.import`
|
||||
- `POST /api/admin/data-tools/export`:需要 `admin.data.export`
|
||||
- `POST /api/admin/data-tools/import`:需要 `admin.data.import`
|
||||
- `POST /api/admin/data-tools/wipe`:需要 `admin.data.import`
|
||||
|
||||
受权限保护的编辑 API:
|
||||
|
||||
|
||||
@@ -219,6 +219,8 @@ VALUES
|
||||
('admin.config.update', 'Update system config', 'Edit management configuration records.', 'System config', true),
|
||||
('admin.config.delete', 'Delete system config', 'Delete management configuration records.', 'System config', true),
|
||||
('admin.config.order', 'Order system config', 'Reorder management configuration records.', 'System config', true),
|
||||
('admin.data.export', 'Export data', 'Export content data bundles.', 'Data tools', true),
|
||||
('admin.data.import', 'Import and wipe data', 'Import content data bundles and wipe content data.', 'Data tools', true),
|
||||
('checklist.create', 'Create checklist tasks', 'Create Daily CheckList tasks.', 'CheckList', true),
|
||||
('checklist.update', 'Update checklist tasks', 'Edit Daily CheckList tasks.', 'CheckList', true),
|
||||
('checklist.delete', 'Delete checklist tasks', 'Delete Daily CheckList tasks.', 'CheckList', true),
|
||||
@@ -712,6 +714,8 @@ CREATE TABLE IF NOT EXISTS pokemon_types (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon (
|
||||
id integer PRIMARY KEY,
|
||||
data_id integer CHECK (data_id > 0),
|
||||
data_identifier text NOT NULL DEFAULT '',
|
||||
display_id integer NOT NULL CHECK (display_id > 0),
|
||||
name text NOT NULL UNIQUE,
|
||||
is_event_item boolean NOT NULL DEFAULT false,
|
||||
@@ -738,6 +742,10 @@ CREATE TABLE IF NOT EXISTS pokemon (
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE pokemon
|
||||
ADD COLUMN IF NOT EXISTS data_id integer CHECK (data_id > 0),
|
||||
ADD COLUMN IF NOT EXISTS data_identifier text NOT NULL DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
|
||||
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
|
||||
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -22,6 +22,19 @@ type QueryValue = string | string[] | undefined;
|
||||
type QueryParams = Record<string, QueryValue>;
|
||||
|
||||
type DbClient = PoolClient;
|
||||
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
|
||||
type DataToolScopeSummary = {
|
||||
scope: DataToolScope;
|
||||
count: number;
|
||||
};
|
||||
type DataToolRows = Record<string, unknown>[];
|
||||
type DataToolScopeData = Record<string, DataToolRows | undefined>;
|
||||
type DataToolsBundle = {
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
scopes: DataToolScope[];
|
||||
data: Partial<Record<DataToolScope, DataToolScopeData>>;
|
||||
};
|
||||
|
||||
type TranslationField = 'name' | 'title' | 'details' | 'genus';
|
||||
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
||||
@@ -110,6 +123,8 @@ type PokemonImageOptionsResult = {
|
||||
};
|
||||
|
||||
type PokemonPayload = {
|
||||
dataId: number | null;
|
||||
dataIdentifier: string;
|
||||
displayId: number;
|
||||
isEventItem: boolean;
|
||||
name: string;
|
||||
@@ -897,28 +912,19 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise<numbe
|
||||
return result.rows[0]?.sortOrder ?? 10;
|
||||
}
|
||||
|
||||
async function nextPokemonInternalId(client: DbClient, displayId: number, isEventItem: boolean): Promise<number> {
|
||||
if (isEventItem) {
|
||||
async function nextPokemonInternalId(
|
||||
client: DbClient,
|
||||
dataId: number | null,
|
||||
isEventItem: boolean
|
||||
): Promise<number> {
|
||||
if (!isEventItem && dataId !== null) {
|
||||
return dataId;
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: number }>(
|
||||
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
|
||||
);
|
||||
const nextId = result.rows[0]?.id ?? 1000000;
|
||||
return nextId === displayId ? nextId + 1 : nextId;
|
||||
}
|
||||
|
||||
if (!isEventItem) {
|
||||
const preferredId = await client.query<{ id: number }>('SELECT id FROM pokemon WHERE id = $1', [displayId]);
|
||||
if (preferredId.rowCount === 0) {
|
||||
return displayId;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.query<{ id: number }>(
|
||||
'SELECT COALESCE(MAX(id), 0) + 1 AS id FROM pokemon WHERE id <> $1',
|
||||
[displayId]
|
||||
);
|
||||
const nextId = result.rows[0]?.id ?? 1;
|
||||
return nextId === displayId ? nextId + 1 : nextId;
|
||||
return result.rows[0]?.id ?? 1000000;
|
||||
}
|
||||
|
||||
async function reorderTableRows(
|
||||
@@ -2160,6 +2166,8 @@ function pokemonProjection(locale: string): string {
|
||||
return `
|
||||
SELECT
|
||||
p.id,
|
||||
p.data_id AS "dataId",
|
||||
p.data_identifier AS "dataIdentifier",
|
||||
p.display_id AS "displayId",
|
||||
${pokemonName} AS name,
|
||||
p.name AS "baseName",
|
||||
@@ -4684,6 +4692,8 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||
const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired');
|
||||
|
||||
return {
|
||||
dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'),
|
||||
dataIdentifier: cleanOptionalText(payload.dataIdentifier),
|
||||
displayId,
|
||||
isEventItem: Boolean(payload.isEventItem),
|
||||
name: cleanName(payload.name, 'server.validation.pokemonNameRequired'),
|
||||
@@ -4702,6 +4712,21 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||
};
|
||||
}
|
||||
|
||||
async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise<void> {
|
||||
if (payload.dataId === null) {
|
||||
payload.dataIdentifier = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await loadPokemonCsvData();
|
||||
const pokemonRow = data.pokemonByLookup.get(String(payload.dataId));
|
||||
if (!pokemonRow) {
|
||||
throw validationError('server.validation.pokemonDataNotFound');
|
||||
}
|
||||
|
||||
payload.dataIdentifier = csvText(pokemonRow, 'identifier');
|
||||
}
|
||||
|
||||
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
|
||||
await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]);
|
||||
await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
|
||||
@@ -4749,14 +4774,17 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
|
||||
|
||||
export async function createPokemon(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
const cleanPayload = cleanPokemonPayload(payload);
|
||||
await normalizePokemonDataIdentity(cleanPayload);
|
||||
|
||||
const id = await withTransaction(async (client) => {
|
||||
const pokemonId = await nextPokemonInternalId(client, cleanPayload.displayId, cleanPayload.isEventItem);
|
||||
const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId, cleanPayload.isEventItem);
|
||||
const sortOrder = await nextSortOrder(client, 'pokemon');
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO pokemon (
|
||||
id,
|
||||
data_id,
|
||||
data_identifier,
|
||||
display_id,
|
||||
name,
|
||||
is_event_item,
|
||||
@@ -4780,10 +4808,12 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
created_by_user_id,
|
||||
updated_by_user_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $22)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $24)
|
||||
`,
|
||||
[
|
||||
pokemonId,
|
||||
cleanPayload.dataId,
|
||||
cleanPayload.dataIdentifier,
|
||||
cleanPayload.displayId,
|
||||
cleanPayload.name,
|
||||
cleanPayload.isEventItem,
|
||||
@@ -4818,6 +4848,10 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
|
||||
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||
const cleanPayload = cleanPokemonPayload(payload);
|
||||
await normalizePokemonDataIdentity(cleanPayload);
|
||||
if (!cleanPayload.isEventItem && cleanPayload.dataId !== null && cleanPayload.dataId !== id) {
|
||||
throw validationError('server.validation.pokemonDataIdMismatch');
|
||||
}
|
||||
const before = await getPokemon(id, defaultLocale);
|
||||
|
||||
const updated = await withTransaction(async (client) => {
|
||||
@@ -4825,30 +4859,34 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
||||
`
|
||||
UPDATE pokemon
|
||||
SET
|
||||
display_id = $1,
|
||||
name = $2,
|
||||
is_event_item = $3,
|
||||
genus = $4,
|
||||
details = $5,
|
||||
height_inches = $6,
|
||||
weight_pounds = $7,
|
||||
environment_id = $8,
|
||||
hp = $9,
|
||||
attack = $10,
|
||||
defense = $11,
|
||||
special_attack = $12,
|
||||
special_defense = $13,
|
||||
speed = $14,
|
||||
image_path = $15,
|
||||
image_style = $16,
|
||||
image_version = $17,
|
||||
image_variant = $18,
|
||||
image_description = $19,
|
||||
updated_by_user_id = $20,
|
||||
data_id = $1,
|
||||
data_identifier = $2,
|
||||
display_id = $3,
|
||||
name = $4,
|
||||
is_event_item = $5,
|
||||
genus = $6,
|
||||
details = $7,
|
||||
height_inches = $8,
|
||||
weight_pounds = $9,
|
||||
environment_id = $10,
|
||||
hp = $11,
|
||||
attack = $12,
|
||||
defense = $13,
|
||||
special_attack = $14,
|
||||
special_defense = $15,
|
||||
speed = $16,
|
||||
image_path = $17,
|
||||
image_style = $18,
|
||||
image_version = $19,
|
||||
image_variant = $20,
|
||||
image_description = $21,
|
||||
updated_by_user_id = $22,
|
||||
updated_at = now()
|
||||
WHERE id = $21
|
||||
WHERE id = $23
|
||||
`,
|
||||
[
|
||||
cleanPayload.dataId,
|
||||
cleanPayload.dataIdentifier,
|
||||
cleanPayload.displayId,
|
||||
cleanPayload.name,
|
||||
cleanPayload.isEventItem,
|
||||
@@ -5736,3 +5774,444 @@ export async function deleteRecipe(id: number, userId: number) {
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const dataToolScopes = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
|
||||
const dataToolMainTables: Record<DataToolScope, string> = {
|
||||
pokemon: 'pokemon',
|
||||
habitats: 'habitats',
|
||||
items: 'items',
|
||||
recipes: 'recipes',
|
||||
checklist: 'daily_checklist_items'
|
||||
};
|
||||
|
||||
const dataToolColumns = {
|
||||
pokemon: [
|
||||
'id',
|
||||
'data_id',
|
||||
'data_identifier',
|
||||
'display_id',
|
||||
'name',
|
||||
'is_event_item',
|
||||
'genus',
|
||||
'details',
|
||||
'height_inches',
|
||||
'weight_pounds',
|
||||
'environment_id',
|
||||
'hp',
|
||||
'attack',
|
||||
'defense',
|
||||
'special_attack',
|
||||
'special_defense',
|
||||
'speed',
|
||||
'image_path',
|
||||
'image_style',
|
||||
'image_version',
|
||||
'image_variant',
|
||||
'image_description',
|
||||
'sort_order',
|
||||
'created_by_user_id',
|
||||
'updated_by_user_id',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
],
|
||||
pokemonTypeLinks: ['pokemon_id', 'type_id', 'slot_order'],
|
||||
pokemonSkills: ['pokemon_id', 'skill_id'],
|
||||
pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'],
|
||||
pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'],
|
||||
habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
||||
habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'],
|
||||
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
|
||||
items: [
|
||||
'id',
|
||||
'name',
|
||||
'category_id',
|
||||
'usage_id',
|
||||
'dyeable',
|
||||
'dual_dyeable',
|
||||
'pattern_editable',
|
||||
'no_recipe',
|
||||
'is_event_item',
|
||||
'image_path',
|
||||
'sort_order',
|
||||
'created_by_user_id',
|
||||
'updated_by_user_id',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
],
|
||||
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
||||
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
|
||||
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
||||
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
||||
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
||||
checklist: ['id', 'title', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
||||
translations: ['entity_type', 'entity_id', 'locale', 'field_name', 'value'],
|
||||
editLogs: ['id', 'entity_type', 'entity_id', 'action', 'user_id', 'changes', 'created_at'],
|
||||
imageUploads: [
|
||||
'id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'entity_name',
|
||||
'path',
|
||||
'original_filename',
|
||||
'mime_type',
|
||||
'byte_size',
|
||||
'created_by_user_id',
|
||||
'created_at'
|
||||
],
|
||||
discussionComments: [
|
||||
'id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'parent_comment_id',
|
||||
'body',
|
||||
'ai_moderation_status',
|
||||
'ai_moderation_language_code',
|
||||
'ai_moderation_content_hash',
|
||||
'ai_moderation_checked_at',
|
||||
'ai_moderation_retry_count',
|
||||
'ai_moderation_updated_at',
|
||||
'created_by_user_id',
|
||||
'deleted_by_user_id',
|
||||
'deleted_at',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
]
|
||||
} as const;
|
||||
|
||||
function isDataToolScope(value: unknown): value is DataToolScope {
|
||||
return typeof value === 'string' && dataToolScopes.includes(value as DataToolScope);
|
||||
}
|
||||
|
||||
function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] {
|
||||
const scopeSet = new Set(scopes);
|
||||
if (scopeSet.has('items')) {
|
||||
scopeSet.add('recipes');
|
||||
}
|
||||
return dataToolScopes.filter((scope) => scopeSet.has(scope));
|
||||
}
|
||||
|
||||
function cleanDataToolScopes(value: unknown): DataToolScope[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw validationError('server.validation.dataToolScopeRequired');
|
||||
}
|
||||
|
||||
const scopes: DataToolScope[] = [];
|
||||
for (const scope of value) {
|
||||
if (!isDataToolScope(scope)) {
|
||||
throw validationError('server.validation.dataToolScopeInvalid');
|
||||
}
|
||||
if (!scopes.includes(scope)) {
|
||||
scopes.push(scope);
|
||||
}
|
||||
}
|
||||
|
||||
if (scopes.length === 0) {
|
||||
throw validationError('server.validation.dataToolScopeRequired');
|
||||
}
|
||||
return normalizeDataToolScopes(scopes);
|
||||
}
|
||||
|
||||
function cleanDataToolsBundle(value: unknown): DataToolsBundle {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw validationError('server.validation.dataToolBundleInvalid');
|
||||
}
|
||||
|
||||
const bundle = value as Record<string, unknown>;
|
||||
if (bundle.version !== 1 || !bundle.data || typeof bundle.data !== 'object' || Array.isArray(bundle.data)) {
|
||||
throw validationError('server.validation.dataToolBundleInvalid');
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
exportedAt: typeof bundle.exportedAt === 'string' ? bundle.exportedAt : new Date().toISOString(),
|
||||
scopes: cleanDataToolScopes(bundle.scopes),
|
||||
data: bundle.data as DataToolsBundle['data']
|
||||
};
|
||||
}
|
||||
|
||||
function dataToolTableRows(data: DataToolScopeData | undefined, key: string): DataToolRows {
|
||||
const rows = data?.[key];
|
||||
if (rows === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== 'object' || Array.isArray(row))) {
|
||||
throw validationError('server.validation.dataToolBundleInvalid');
|
||||
}
|
||||
return rows as DataToolRows;
|
||||
}
|
||||
|
||||
function dataToolDataWithRows(key: string, ...sources: Array<DataToolScopeData | undefined>): DataToolScopeData | undefined {
|
||||
return sources.find((source) => source?.[key] !== undefined);
|
||||
}
|
||||
|
||||
async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise<DataToolRows> {
|
||||
const result = await client.query<Record<string, unknown>>(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
function normalizeImportValue(column: string, value: unknown): unknown {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (column === 'changes' && typeof value !== 'string') {
|
||||
return JSON.stringify(value ?? []);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
||||
for (const row of rows) {
|
||||
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
||||
const values = columns.map((column) => normalizeImportValue(column, row[column]));
|
||||
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
|
||||
}
|
||||
}
|
||||
|
||||
async function resetIdentity(client: DbClient, tableName: string): Promise<void> {
|
||||
const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`);
|
||||
const maxId = result.rows[0]?.maxId ?? null;
|
||||
if (maxId === null) {
|
||||
await client.query(`ALTER TABLE ${tableName} ALTER COLUMN id RESTART WITH 1`);
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query('SELECT setval(pg_get_serial_sequence($1, $2), $3, true)', [tableName, 'id', maxId]);
|
||||
}
|
||||
|
||||
async function resetDataToolIdentities(client: DbClient): Promise<void> {
|
||||
for (const tableName of ['daily_checklist_items', 'items', 'recipes', 'habitats', 'wiki_edit_logs', 'entity_image_uploads', 'entity_discussion_comments']) {
|
||||
await resetIdentity(client, tableName);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGenericEntityRows(client: DbClient, entityTypes: string[]): Promise<void> {
|
||||
await client.query('DELETE FROM entity_discussion_comments WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
||||
await client.query('DELETE FROM entity_image_uploads WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
||||
await client.query('DELETE FROM wiki_edit_logs WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
||||
await client.query('DELETE FROM entity_translations WHERE entity_type = ANY($1::text[])', [entityTypes]);
|
||||
}
|
||||
|
||||
async function wipeRecipesData(client: DbClient): Promise<void> {
|
||||
await deleteGenericEntityRows(client, ['recipes']);
|
||||
await client.query('DELETE FROM recipe_acquisition_methods');
|
||||
await client.query('DELETE FROM recipe_materials');
|
||||
await client.query('DELETE FROM recipes');
|
||||
}
|
||||
|
||||
async function wipeItemsData(client: DbClient): Promise<void> {
|
||||
await wipeRecipesData(client);
|
||||
await deleteGenericEntityRows(client, ['items']);
|
||||
await client.query('DELETE FROM item_acquisition_methods');
|
||||
await client.query('DELETE FROM item_favorite_things');
|
||||
await client.query('DELETE FROM habitat_recipe_items');
|
||||
await client.query('DELETE FROM pokemon_skill_item_drops');
|
||||
await client.query('DELETE FROM items');
|
||||
}
|
||||
|
||||
async function wipePokemonData(client: DbClient): Promise<void> {
|
||||
await deleteGenericEntityRows(client, ['pokemon']);
|
||||
await client.query('DELETE FROM habitat_pokemon');
|
||||
await client.query('DELETE FROM pokemon_skill_item_drops');
|
||||
await client.query('DELETE FROM pokemon_pokemon_types');
|
||||
await client.query('DELETE FROM pokemon_skills');
|
||||
await client.query('DELETE FROM pokemon_favorite_things');
|
||||
await client.query('DELETE FROM pokemon');
|
||||
}
|
||||
|
||||
async function wipeHabitatsData(client: DbClient): Promise<void> {
|
||||
await deleteGenericEntityRows(client, ['habitats']);
|
||||
await client.query('DELETE FROM habitat_recipe_items');
|
||||
await client.query('DELETE FROM habitat_pokemon');
|
||||
await client.query('DELETE FROM habitats');
|
||||
}
|
||||
|
||||
async function wipeChecklistData(client: DbClient): Promise<void> {
|
||||
await deleteGenericEntityRows(client, ['daily-checklist-items']);
|
||||
await client.query('DELETE FROM daily_checklist_items');
|
||||
}
|
||||
|
||||
async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], resetIdentities = true): Promise<void> {
|
||||
const scopeSet = new Set(scopes);
|
||||
if (scopeSet.has('items')) {
|
||||
await wipeItemsData(client);
|
||||
} else if (scopeSet.has('recipes')) {
|
||||
await wipeRecipesData(client);
|
||||
}
|
||||
if (scopeSet.has('pokemon')) {
|
||||
await wipePokemonData(client);
|
||||
}
|
||||
if (scopeSet.has('habitats')) {
|
||||
await wipeHabitatsData(client);
|
||||
}
|
||||
if (scopeSet.has('checklist')) {
|
||||
await wipeChecklistData(client);
|
||||
}
|
||||
if (resetIdentities) {
|
||||
await resetDataToolIdentities(client);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportGenericScopeData(client: DbClient, entityType: string, includeImages: boolean): Promise<DataToolScopeData> {
|
||||
const data: DataToolScopeData = {
|
||||
translations: await tableRows(client, 'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name', [entityType]),
|
||||
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', [entityType]),
|
||||
discussionComments: await tableRows(
|
||||
client,
|
||||
`
|
||||
SELECT *
|
||||
FROM entity_discussion_comments
|
||||
WHERE entity_type = $1
|
||||
ORDER BY parent_comment_id NULLS FIRST, id
|
||||
`,
|
||||
[entityType]
|
||||
)
|
||||
};
|
||||
|
||||
if (includeImages) {
|
||||
data.imageUploads = await tableRows(client, 'SELECT * FROM entity_image_uploads WHERE entity_type = $1 ORDER BY id', [entityType]);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<DataToolScopeData> {
|
||||
if (scope === 'pokemon') {
|
||||
return {
|
||||
pokemon: await tableRows(client, 'SELECT * FROM pokemon ORDER BY sort_order, id'),
|
||||
pokemonTypeLinks: await tableRows(client, 'SELECT * FROM pokemon_pokemon_types ORDER BY pokemon_id, slot_order'),
|
||||
pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
|
||||
pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),
|
||||
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
||||
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
|
||||
...(await exportGenericScopeData(client, 'pokemon', true))
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === 'habitats') {
|
||||
return {
|
||||
habitats: await tableRows(client, 'SELECT * FROM habitats ORDER BY sort_order, id'),
|
||||
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
|
||||
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
|
||||
...(await exportGenericScopeData(client, 'habitats', true))
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === 'items') {
|
||||
return {
|
||||
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
|
||||
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
|
||||
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
|
||||
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
||||
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
|
||||
...(await exportGenericScopeData(client, 'items', true))
|
||||
};
|
||||
}
|
||||
|
||||
if (scope === 'recipes') {
|
||||
return {
|
||||
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
|
||||
recipeAcquisitionMethods: await tableRows(client, 'SELECT * FROM recipe_acquisition_methods ORDER BY recipe_id, acquisition_method_id'),
|
||||
recipeMaterials: await tableRows(client, 'SELECT * FROM recipe_materials ORDER BY recipe_id, item_id'),
|
||||
...(await exportGenericScopeData(client, 'recipes', false))
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
checklist: await tableRows(client, 'SELECT * FROM daily_checklist_items ORDER BY sort_order, id'),
|
||||
translations: await tableRows(
|
||||
client,
|
||||
'SELECT * FROM entity_translations WHERE entity_type = $1 ORDER BY entity_id, locale, field_name',
|
||||
['daily-checklist-items']
|
||||
),
|
||||
editLogs: await tableRows(client, 'SELECT * FROM wiki_edit_logs WHERE entity_type = $1 ORDER BY id', ['daily-checklist-items'])
|
||||
};
|
||||
}
|
||||
|
||||
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
||||
const itemData = bundle.data.items;
|
||||
const pokemonData = bundle.data.pokemon;
|
||||
const habitatData = bundle.data.habitats;
|
||||
const checklistData = bundle.data.checklist;
|
||||
const recipeData = bundle.data.recipes;
|
||||
|
||||
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
|
||||
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
|
||||
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
||||
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
|
||||
await insertRows(client, 'recipes', dataToolColumns.recipes, dataToolTableRows(recipeData, 'recipes'));
|
||||
}
|
||||
|
||||
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
||||
const itemData = bundle.data.items;
|
||||
const pokemonData = bundle.data.pokemon;
|
||||
const habitatData = bundle.data.habitats;
|
||||
const recipeData = bundle.data.recipes;
|
||||
const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData);
|
||||
const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData);
|
||||
const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData);
|
||||
|
||||
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
|
||||
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
|
||||
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
|
||||
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
||||
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
||||
await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops'));
|
||||
await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods'));
|
||||
await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials'));
|
||||
await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems'));
|
||||
await insertRows(client, 'habitat_pokemon', dataToolColumns.habitatPokemon, dataToolTableRows(habitatPokemonData, 'habitatPokemon'));
|
||||
}
|
||||
|
||||
async function importGenericScopeRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
||||
for (const scope of bundle.scopes) {
|
||||
const data = bundle.data[scope];
|
||||
await insertRows(client, 'entity_translations', dataToolColumns.translations, dataToolTableRows(data, 'translations'));
|
||||
await insertRows(client, 'wiki_edit_logs', dataToolColumns.editLogs, dataToolTableRows(data, 'editLogs'));
|
||||
await insertRows(client, 'entity_image_uploads', dataToolColumns.imageUploads, dataToolTableRows(data, 'imageUploads'));
|
||||
await insertRows(client, 'entity_discussion_comments', dataToolColumns.discussionComments, dataToolTableRows(data, 'discussionComments'));
|
||||
}
|
||||
}
|
||||
|
||||
async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
||||
await importScopeMainRows(client, bundle);
|
||||
await importScopeRelationRows(client, bundle);
|
||||
await importGenericScopeRows(client, bundle);
|
||||
await resetDataToolIdentities(client);
|
||||
}
|
||||
|
||||
export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> {
|
||||
const scopes: DataToolScopeSummary[] = [];
|
||||
for (const scope of dataToolScopes) {
|
||||
const result = await queryOne<{ count: number }>(`SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`);
|
||||
scopes.push({ scope, count: result?.count ?? 0 });
|
||||
}
|
||||
return { scopes };
|
||||
}
|
||||
|
||||
export async function exportAdminData(payload: Record<string, unknown>): Promise<DataToolsBundle> {
|
||||
const scopes = cleanDataToolScopes(payload.scopes);
|
||||
return withTransaction(async (client) => {
|
||||
const data: DataToolsBundle['data'] = {};
|
||||
for (const scope of scopes) {
|
||||
data[scope] = await exportScopeData(client, scope);
|
||||
}
|
||||
return { version: 1, exportedAt: new Date().toISOString(), scopes, data };
|
||||
});
|
||||
}
|
||||
|
||||
export async function importAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
||||
const bundle = cleanDataToolsBundle(payload.bundle);
|
||||
await withTransaction(async (client) => {
|
||||
await wipeDataToolScopes(client, bundle.scopes, false);
|
||||
await importDataToolsBundle(client, bundle);
|
||||
});
|
||||
return getAdminDataToolsSummary();
|
||||
}
|
||||
|
||||
export async function wipeAdminData(payload: Record<string, unknown>): Promise<{ scopes: DataToolScopeSummary[] }> {
|
||||
const scopes = cleanDataToolScopes(payload.scopes);
|
||||
await withTransaction(async (client) => {
|
||||
await wipeDataToolScopes(client, scopes);
|
||||
});
|
||||
return getAdminDataToolsSummary();
|
||||
}
|
||||
|
||||
@@ -59,14 +59,17 @@ import {
|
||||
deleteLifePostReaction,
|
||||
deletePokemon,
|
||||
deleteRecipe,
|
||||
exportAdminData,
|
||||
fetchPokemonData,
|
||||
fetchPokemonImageOptions,
|
||||
getAdminDataToolsSummary,
|
||||
getHabitat,
|
||||
getItem,
|
||||
getOptions,
|
||||
getPokemon,
|
||||
getPublicUserProfile,
|
||||
getRecipe,
|
||||
importAdminData,
|
||||
isConfigType,
|
||||
listEntityDiscussionComments,
|
||||
listConfig,
|
||||
@@ -101,7 +104,8 @@ import {
|
||||
updateLanguage,
|
||||
updateLifePost,
|
||||
updatePokemon,
|
||||
updateRecipe
|
||||
updateRecipe,
|
||||
wipeAdminData
|
||||
} from './queries.ts';
|
||||
import {
|
||||
getAiModerationSettings,
|
||||
@@ -178,7 +182,7 @@ app.setErrorHandler(async (error, _request, reply) => {
|
||||
return reply.code(409).send({ message: await serverMessage(locale, 'duplicate') });
|
||||
}
|
||||
|
||||
if (pgError.code === '23514') {
|
||||
if (pgError.code === '23502' || pgError.code === '23514') {
|
||||
return reply.code(400).send({ message: await serverMessage(locale, 'invalidField') });
|
||||
}
|
||||
|
||||
@@ -212,6 +216,287 @@ function requestLocale(request: FastifyRequest): string {
|
||||
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
||||
}
|
||||
|
||||
type ProjectUpdatesRepository = {
|
||||
name: string;
|
||||
fullName: string;
|
||||
url: string;
|
||||
defaultBranch: string;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
type ProjectUpdateCommit = {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
title: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
authorName: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ProjectUpdateRelease = {
|
||||
tagName: string;
|
||||
name: string;
|
||||
publishedAt: string | null;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ProjectCommitPage = {
|
||||
items: ProjectUpdateCommit[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
type ProjectUpdatesCursor = {
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
type ProjectUpdatesResponse = {
|
||||
repository: ProjectUpdatesRepository;
|
||||
commits: ProjectCommitPage;
|
||||
releases: ProjectUpdateRelease[];
|
||||
};
|
||||
|
||||
const projectUpdatesConfig = {
|
||||
apiBaseUrl: 'https://git.tootaio.com/api/v1',
|
||||
publicBaseUrl: 'https://git.tootaio.com',
|
||||
owner: 'Kingsmai',
|
||||
repo: 'pokopiawiki.tootaio.com',
|
||||
commitLimit: 5,
|
||||
maxCommitLimit: 20,
|
||||
releaseLimit: 3,
|
||||
timeoutMs: 5000
|
||||
} as const;
|
||||
|
||||
function projectRepositoryPath(): string {
|
||||
return `${encodeURIComponent(projectUpdatesConfig.owner)}/${encodeURIComponent(projectUpdatesConfig.repo)}`;
|
||||
}
|
||||
|
||||
function projectRepositoryUrl(): string {
|
||||
return `${projectUpdatesConfig.publicBaseUrl}/${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`;
|
||||
}
|
||||
|
||||
function projectApiUrl(path = '', params: Record<string, string | number | boolean> = {}): string {
|
||||
const apiBaseUrl = projectUpdatesConfig.apiBaseUrl.replace(/\/$/, '');
|
||||
const url = new URL(`${apiBaseUrl}/repos/${projectRepositoryPath()}${path}`);
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function objectField(record: Record<string, unknown> | null, key: string): Record<string, unknown> | null {
|
||||
if (!record) return null;
|
||||
const value = record[key];
|
||||
return isObjectRecord(value) ? value : null;
|
||||
}
|
||||
|
||||
function stringField(record: Record<string, unknown> | null, key: string): string | null {
|
||||
if (!record) return null;
|
||||
const value = record[key];
|
||||
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
||||
}
|
||||
|
||||
function normalizedDate(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function projectCommitTitle(message: string | null, fallback: string): string {
|
||||
const [firstLine] = (message ?? '').split('\n');
|
||||
return firstLine?.trim() || fallback;
|
||||
}
|
||||
|
||||
function projectUpdatesQueryValue(value: string | string[] | undefined): string | null {
|
||||
const rawValue = Array.isArray(value) ? value[0] : value;
|
||||
return rawValue?.trim() || null;
|
||||
}
|
||||
|
||||
function cleanProjectUpdatesLimit(query: Record<string, string | string[] | undefined>): number {
|
||||
const rawLimit = Number(projectUpdatesQueryValue(query.limit));
|
||||
if (!Number.isInteger(rawLimit)) {
|
||||
return projectUpdatesConfig.commitLimit;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(rawLimit, 1), projectUpdatesConfig.maxCommitLimit);
|
||||
}
|
||||
|
||||
function encodeProjectUpdatesCursor(page: number, limit: number): string {
|
||||
return Buffer.from(JSON.stringify({ page, limit }), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
function decodeProjectUpdatesCursor(cursor: string | null): ProjectUpdatesCursor | null {
|
||||
if (!cursor) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
|
||||
if (!isObjectRecord(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { page, limit } = payload;
|
||||
if (
|
||||
typeof page === 'number' &&
|
||||
Number.isInteger(page) &&
|
||||
page > 0 &&
|
||||
typeof limit === 'number' &&
|
||||
Number.isInteger(limit) &&
|
||||
limit > 0 &&
|
||||
limit <= projectUpdatesConfig.maxCommitLimit
|
||||
) {
|
||||
return { page, limit };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackProjectRepository(): ProjectUpdatesRepository {
|
||||
return {
|
||||
name: projectUpdatesConfig.repo,
|
||||
fullName: `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
|
||||
url: projectRepositoryUrl(),
|
||||
defaultBranch: 'main',
|
||||
updatedAt: null
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProjectJson(path = '', params: Record<string, string | number | boolean> = {}): Promise<unknown> {
|
||||
const response = await fetch(projectApiUrl(path, params), {
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
},
|
||||
signal: AbortSignal.timeout(projectUpdatesConfig.timeoutMs)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Project updates source failed (${response.status})`);
|
||||
}
|
||||
|
||||
return response.json() as Promise<unknown>;
|
||||
}
|
||||
|
||||
function mapProjectRepository(value: unknown): ProjectUpdatesRepository {
|
||||
if (!isObjectRecord(value)) {
|
||||
return fallbackProjectRepository();
|
||||
}
|
||||
|
||||
return {
|
||||
name: stringField(value, 'name') ?? projectUpdatesConfig.repo,
|
||||
fullName: stringField(value, 'full_name') ?? `${projectUpdatesConfig.owner}/${projectUpdatesConfig.repo}`,
|
||||
url: projectRepositoryUrl(),
|
||||
defaultBranch: stringField(value, 'default_branch') ?? 'main',
|
||||
updatedAt: normalizedDate(stringField(value, 'updated_at'))
|
||||
};
|
||||
}
|
||||
|
||||
function mapProjectCommit(value: unknown): ProjectUpdateCommit | null {
|
||||
if (!isObjectRecord(value)) return null;
|
||||
|
||||
const sha = stringField(value, 'sha');
|
||||
if (!sha) return null;
|
||||
|
||||
const commit = objectField(value, 'commit');
|
||||
const commitAuthor = objectField(commit, 'author');
|
||||
const message = stringField(commit, 'message') ?? sha.slice(0, 7);
|
||||
const fallback = sha.slice(0, 7);
|
||||
const createdAt =
|
||||
normalizedDate(stringField(value, 'created')) ??
|
||||
normalizedDate(stringField(commitAuthor, 'date')) ??
|
||||
normalizedDate(stringField(objectField(commit, 'committer'), 'date'));
|
||||
|
||||
if (!createdAt) return null;
|
||||
|
||||
return {
|
||||
sha,
|
||||
shortSha: sha.slice(0, 7),
|
||||
title: projectCommitTitle(message, fallback),
|
||||
message,
|
||||
createdAt,
|
||||
authorName:
|
||||
stringField(commitAuthor, 'name') ??
|
||||
stringField(objectField(value, 'author'), 'login') ??
|
||||
projectUpdatesConfig.owner,
|
||||
url: `${projectRepositoryUrl()}/commit/${sha}`
|
||||
};
|
||||
}
|
||||
|
||||
function mapProjectRelease(value: unknown): ProjectUpdateRelease | null {
|
||||
if (!isObjectRecord(value)) return null;
|
||||
|
||||
const tagName = stringField(value, 'tag_name');
|
||||
if (!tagName) return null;
|
||||
|
||||
return {
|
||||
tagName,
|
||||
name: stringField(value, 'name') ?? tagName,
|
||||
publishedAt: normalizedDate(stringField(value, 'published_at')) ?? normalizedDate(stringField(value, 'created_at')),
|
||||
url: `${projectRepositoryUrl()}/releases/tag/${encodeURIComponent(tagName)}`
|
||||
};
|
||||
}
|
||||
|
||||
function logProjectUpdatesError(source: string, error: unknown): void {
|
||||
app.log.warn({ err: error, source }, 'Project updates source unavailable');
|
||||
}
|
||||
|
||||
async function getProjectCommitPage(query: Record<string, string | string[] | undefined>): Promise<ProjectCommitPage> {
|
||||
const cursor = decodeProjectUpdatesCursor(projectUpdatesQueryValue(query.cursor));
|
||||
const limit = cursor?.limit ?? cleanProjectUpdatesLimit(query);
|
||||
const page = cursor?.page ?? 1;
|
||||
const value = await fetchProjectJson('/commits', {
|
||||
page,
|
||||
limit: limit + 1,
|
||||
stat: false,
|
||||
files: false,
|
||||
verification: false
|
||||
});
|
||||
const commits = Array.isArray(value)
|
||||
? value.map(mapProjectCommit).filter((commit): commit is ProjectUpdateCommit => commit !== null)
|
||||
: [];
|
||||
const hasMore = commits.length > limit;
|
||||
|
||||
return {
|
||||
items: commits.slice(0, limit),
|
||||
nextCursor: hasMore ? encodeProjectUpdatesCursor(page + 1, limit) : null,
|
||||
hasMore
|
||||
};
|
||||
}
|
||||
|
||||
async function getProjectUpdates(query: Record<string, string | string[] | undefined> = {}): Promise<ProjectUpdatesResponse> {
|
||||
const [repository, commits, releases] = await Promise.all([
|
||||
fetchProjectJson()
|
||||
.then(mapProjectRepository)
|
||||
.catch((error: unknown) => {
|
||||
logProjectUpdatesError('repository', error);
|
||||
return fallbackProjectRepository();
|
||||
}),
|
||||
getProjectCommitPage(query).catch((error: unknown) => {
|
||||
logProjectUpdatesError('commits', error);
|
||||
throw error;
|
||||
}),
|
||||
fetchProjectJson('/releases', { limit: projectUpdatesConfig.releaseLimit, draft: false, 'pre-release': false })
|
||||
.then((value) =>
|
||||
Array.isArray(value) ? value.map(mapProjectRelease).filter((release): release is ProjectUpdateRelease => release !== null) : []
|
||||
)
|
||||
.catch((error: unknown) => {
|
||||
logProjectUpdatesError('releases', error);
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
|
||||
return { repository, commits, releases };
|
||||
}
|
||||
|
||||
function serverMessage(
|
||||
locale: string,
|
||||
key:
|
||||
@@ -844,6 +1129,10 @@ app.get('/api/system-wordings', async (request) => getSystemWordings(requestLoca
|
||||
|
||||
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||
|
||||
app.get('/api/project-updates', async (request) =>
|
||||
getProjectUpdates(request.query as Record<string, string | string[] | undefined>)
|
||||
);
|
||||
|
||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||
|
||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||
@@ -1532,6 +1821,26 @@ app.put('/api/admin/rate-limits', async (request, reply) => {
|
||||
return updateRateLimitSettings(request.body as Record<string, unknown>, user.id);
|
||||
});
|
||||
|
||||
app.get('/api/admin/data-tools/summary', async (request, reply) => {
|
||||
const user = await requireAnyPermission(request, reply, ['admin.data.export', 'admin.data.import']);
|
||||
return user ? getAdminDataToolsSummary() : undefined;
|
||||
});
|
||||
|
||||
app.post('/api/admin/data-tools/export', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.export', 'adminWrite');
|
||||
return user ? exportAdminData(request.body as Record<string, unknown>) : undefined;
|
||||
});
|
||||
|
||||
app.post('/api/admin/data-tools/import', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||
return user ? importAdminData(request.body as Record<string, unknown>) : undefined;
|
||||
});
|
||||
|
||||
app.post('/api/admin/data-tools/wipe', async (request, reply) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'admin.data.import', 'adminWrite');
|
||||
return user ? wipeAdminData(request.body as Record<string, unknown>) : undefined;
|
||||
});
|
||||
|
||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||
const user = await requirePermission(request, reply, 'admin.config.read');
|
||||
if (!user) {
|
||||
|
||||
@@ -9,6 +9,8 @@ export const iconCancel: AppIcon = 'mdi:close';
|
||||
export const iconCheck: AppIcon = 'mdi:check';
|
||||
export const iconChecklist: AppIcon = 'mdi:checkbox-marked-outline';
|
||||
export const iconChevronDown: AppIcon = 'mdi:chevron-down';
|
||||
export const iconChevronRight: AppIcon = 'mdi:chevron-right';
|
||||
export const iconChevronUp: AppIcon = 'mdi:chevron-up';
|
||||
export const iconClose: AppIcon = 'mdi:close';
|
||||
export const iconComment: AppIcon = 'mdi:comment-outline';
|
||||
export const iconCopy: AppIcon = 'mdi:content-copy';
|
||||
@@ -19,6 +21,8 @@ export const iconDreamIsland: AppIcon = 'mdi:palm-tree';
|
||||
export const iconEdit: AppIcon = 'mdi:pencil-outline';
|
||||
export const iconError: AppIcon = 'mdi:close-circle-outline';
|
||||
export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||
export const iconExternal: AppIcon = 'mdi:open-in-new';
|
||||
export const iconGitCommit: AppIcon = 'mdi:source-commit';
|
||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||
export const iconHome: AppIcon = 'mdi:home-variant-outline';
|
||||
export const iconImage: AppIcon = 'mdi:image-outline';
|
||||
|
||||
@@ -10,6 +10,7 @@ import RecipeList from '../views/RecipeList.vue';
|
||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
||||
import LegalView from '../views/LegalView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
@@ -180,6 +181,17 @@ export const router = createRouter({
|
||||
},
|
||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
||||
{
|
||||
path: '/project-updates',
|
||||
component: ProjectUpdatesView,
|
||||
meta: {
|
||||
seo: seo({
|
||||
titleKey: 'pages.projectUpdates.title',
|
||||
descriptionKey: 'pages.projectUpdates.subtitle',
|
||||
canonicalPath: '/project-updates'
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
component: LegalView,
|
||||
|
||||
@@ -64,6 +64,48 @@ export interface UserSummary {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface ProjectUpdatesRepository {
|
||||
name: string;
|
||||
fullName: string;
|
||||
url: string;
|
||||
defaultBranch: string;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface ProjectUpdateCommit {
|
||||
sha: string;
|
||||
shortSha: string;
|
||||
title: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
authorName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProjectUpdateRelease {
|
||||
tagName: string;
|
||||
name: string;
|
||||
publishedAt: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ProjectCommitPage {
|
||||
items: ProjectUpdateCommit[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectUpdates {
|
||||
repository: ProjectUpdatesRepository;
|
||||
commits: ProjectCommitPage;
|
||||
releases: ProjectUpdateRelease[];
|
||||
}
|
||||
|
||||
export interface ProjectUpdatesParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface EntityImage {
|
||||
path: string;
|
||||
url: string;
|
||||
@@ -109,6 +151,8 @@ export interface EditHistoryEntry {
|
||||
|
||||
export interface Pokemon extends EditInfo {
|
||||
id: number;
|
||||
dataId?: number | null;
|
||||
dataIdentifier?: string;
|
||||
displayId: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
@@ -252,6 +296,24 @@ export interface DailyChecklistItem {
|
||||
translations?: TranslationMap;
|
||||
}
|
||||
|
||||
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
|
||||
|
||||
export interface DataToolScopeSummary {
|
||||
scope: DataToolScope;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DataToolsSummary {
|
||||
scopes: DataToolScopeSummary[];
|
||||
}
|
||||
|
||||
export interface DataToolsBundle {
|
||||
version: 1;
|
||||
exportedAt: string;
|
||||
scopes: DataToolScope[];
|
||||
data: Partial<Record<DataToolScope, Record<string, unknown[]>>>;
|
||||
}
|
||||
|
||||
export type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||
export type LifeReactionCounts = Record<LifeReactionType, number>;
|
||||
export type AiModerationStatus = 'unreviewed' | 'reviewing' | 'approved' | 'rejected' | 'failed';
|
||||
@@ -492,6 +554,8 @@ export type ConfigType =
|
||||
| 'game-versions';
|
||||
|
||||
export interface PokemonPayload {
|
||||
dataId?: number | null;
|
||||
dataIdentifier?: string;
|
||||
displayId: number;
|
||||
isEventItem: boolean;
|
||||
name: string;
|
||||
@@ -836,6 +900,13 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
|
||||
|
||||
export const api = {
|
||||
languages: () => getJson<Language[]>('/api/languages'),
|
||||
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
||||
getJson<ProjectUpdates>(
|
||||
`/api/project-updates${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit
|
||||
})}`
|
||||
),
|
||||
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
|
||||
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
|
||||
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
|
||||
@@ -853,6 +924,10 @@ export const api = {
|
||||
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
|
||||
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
|
||||
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),
|
||||
dataToolsSummary: () => getJson<DataToolsSummary>('/api/admin/data-tools/summary'),
|
||||
exportDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsBundle>('/api/admin/data-tools/export', 'POST', { scopes }),
|
||||
importDataTools: (bundle: DataToolsBundle) => sendJson<DataToolsSummary>('/api/admin/data-tools/import', 'POST', { bundle }),
|
||||
wipeDataTools: (scopes: DataToolScope[]) => sendJson<DataToolsSummary>('/api/admin/data-tools/wipe', 'POST', { scopes }),
|
||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||
verifyEmail: (token: string) =>
|
||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||
|
||||
@@ -835,6 +835,59 @@ button:disabled,
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.data-tool-grid {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.data-tool-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.data-tool-panel:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.data-tool-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.data-tool-panel__header h3 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.data-tool-scope-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.data-tool-scope {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 0;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.data-tool-scope input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.pokemon-edit-form {
|
||||
height: clamp(420px, calc(100dvh - 188px), 640px);
|
||||
min-height: 0;
|
||||
@@ -4852,6 +4905,332 @@ button:disabled,
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.home-project-updates__panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-control);
|
||||
}
|
||||
|
||||
.home-project-updates__repo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-project-updates__repo-label,
|
||||
.home-project-updates__updated {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.home-project-updates__repo a {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-weight: 950;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.home-project-updates__repo a:hover {
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.home-project-updates__updated {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.home-project-updates__skeleton,
|
||||
.home-project-updates__content,
|
||||
.home-project-updates__group,
|
||||
.home-project-updates__commit {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.home-project-updates__skeleton,
|
||||
.home-project-updates__content {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.home-project-updates__skeleton {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.home-project-updates__group {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.home-project-updates__group h3 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.home-project-updates__list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.home-project-updates__item {
|
||||
min-height: 78px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.home-project-updates__item:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.home-project-updates__commit {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.home-project-updates__title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.home-project-updates__title strong {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-weight: 950;
|
||||
line-height: 1.28;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.home-project-updates__sha {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--surface-soft);
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.home-project-updates__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.home-project-updates__link {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.home-project-updates__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.project-updates-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-control);
|
||||
}
|
||||
|
||||
.project-updates-panel h2 {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 24px;
|
||||
font-weight: 950;
|
||||
line-height: 1.12;
|
||||
}
|
||||
|
||||
.project-updates-repo {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.project-updates-repo__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--pokemon-yellow);
|
||||
box-shadow: 0 3px 0 var(--line-strong);
|
||||
color: #172036;
|
||||
}
|
||||
|
||||
.project-updates-repo__copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.project-updates-repo__copy span,
|
||||
.project-updates-repo__meta {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.project-updates-repo__copy a {
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-weight: 950;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-updates-repo__copy a:hover {
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.project-updates-list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.project-updates-list__item {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.project-updates-list__item:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.project-updates-list__row,
|
||||
.project-updates-list__item:not(.project-updates-list__item--commit) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.project-updates-list__main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.project-updates-list__title {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.project-updates-list__title strong {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-weight: 950;
|
||||
line-height: 1.28;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-updates-list__sha {
|
||||
flex: 0 0 auto;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--surface-soft);
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.project-updates-list__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.project-updates-list__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.project-updates-message {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.project-updates-message span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.project-updates-message pre {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.project-updates-more-skeleton {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 8px 0 2px;
|
||||
}
|
||||
|
||||
.project-updates-sentinel {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.project-updates-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
@@ -5898,6 +6277,21 @@ button:disabled,
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.home-project-updates__updated {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.project-updates-repo,
|
||||
.project-updates-list__row,
|
||||
.project-updates-list__item:not(.project-updates-list__item--commit) {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.project-updates-list__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.appearance-row__main {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -5973,6 +6367,36 @@ button:disabled,
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home-project-updates__item,
|
||||
.home-project-updates__title {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.home-project-updates__item {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.home-project-updates__title {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.home-project-updates__link {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.project-updates-panel {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.project-updates-list__title {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.project-updates-list__actions .ui-button,
|
||||
.project-updates-list__item > .ui-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-dex__screen {
|
||||
min-height: 420px;
|
||||
margin: 12px;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
iconRecipe,
|
||||
iconSave,
|
||||
iconTranslate,
|
||||
iconUpload,
|
||||
type AppIcon
|
||||
} from '../icons';
|
||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||
@@ -36,6 +37,9 @@ import {
|
||||
type AuthUser,
|
||||
type AdminUser,
|
||||
type ConfigType,
|
||||
type DataToolScope,
|
||||
type DataToolsBundle,
|
||||
type DataToolsSummary,
|
||||
type DailyChecklistItem,
|
||||
type GameVersion,
|
||||
type Habitat,
|
||||
@@ -65,6 +69,7 @@ type AdminTab =
|
||||
| 'permissions'
|
||||
| 'rateLimits'
|
||||
| 'aiModeration'
|
||||
| 'dataTools'
|
||||
| 'config'
|
||||
| 'languages'
|
||||
| 'wordings'
|
||||
@@ -97,6 +102,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
|
||||
'upload',
|
||||
'fetch'
|
||||
];
|
||||
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
|
||||
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
|
||||
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
|
||||
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
|
||||
@@ -113,6 +119,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
permissions: iconKey,
|
||||
rateLimits: iconAdmin,
|
||||
aiModeration: iconAdmin,
|
||||
dataTools: iconAdmin,
|
||||
config: iconAdmin,
|
||||
languages: iconTranslate,
|
||||
wordings: iconTranslate,
|
||||
@@ -140,7 +147,8 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
||||
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
|
||||
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
|
||||
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
|
||||
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }
|
||||
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
|
||||
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -200,6 +208,7 @@ const habitatRows = ref<Habitat[]>([]);
|
||||
const wordingRows = ref<SystemWording[]>([]);
|
||||
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
||||
const rateLimitSettings = ref<RateLimitSettings | null>(null);
|
||||
const dataToolsSummary = ref<DataToolsSummary | null>(null);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const busy = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
@@ -251,10 +260,16 @@ const userRoleModalOpen = ref(false);
|
||||
const roleModalOpen = ref(false);
|
||||
const rolePermissionsModalOpen = ref(false);
|
||||
const permissionModalOpen = ref(false);
|
||||
const dataToolImportModalOpen = ref(false);
|
||||
const dataToolWipeModalOpen = ref(false);
|
||||
const wordingLocale = ref(getCurrentLocale());
|
||||
const wordingModule = ref('');
|
||||
const wordingSurface = ref<SystemWordingSurface | ''>('');
|
||||
const wordingMissingOnly = ref(false);
|
||||
const selectedExportScopes = ref<DataToolScope[]>(['pokemon']);
|
||||
const selectedWipeScopes = ref<DataToolScope[]>([]);
|
||||
const pendingImportBundle = ref<DataToolsBundle | null>(null);
|
||||
const dataToolConfirmText = ref('');
|
||||
|
||||
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
|
||||
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
|
||||
@@ -352,6 +367,23 @@ const rateLimitPolicyOptions = computed<Array<{ value: RateLimitPolicyKey; label
|
||||
{ value: 'upload', label: t('pages.admin.rateLimitUpload') },
|
||||
{ value: 'fetch', label: t('pages.admin.rateLimitFetch') }
|
||||
]);
|
||||
const dataToolScopeOptions = computed<Array<{ value: DataToolScope; label: string; count: number }>>(() =>
|
||||
dataToolScopeKeys.map((scope) => ({
|
||||
value: scope,
|
||||
label: t(`pages.admin.dataToolScope${scope.charAt(0).toUpperCase()}${scope.slice(1)}`),
|
||||
count: dataToolsSummary.value?.scopes.find((item) => item.scope === scope)?.count ?? 0
|
||||
}))
|
||||
);
|
||||
const importScopeLabels = computed(() =>
|
||||
(pendingImportBundle.value?.scopes ?? [])
|
||||
.map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope)
|
||||
.join(' / ')
|
||||
);
|
||||
const wipeScopeLabels = computed(() =>
|
||||
selectedWipeScopes.value
|
||||
.map((scope) => dataToolScopeOptions.value.find((option) => option.value === scope)?.label ?? scope)
|
||||
.join(' / ')
|
||||
);
|
||||
const filteredWordingRows = computed(() =>
|
||||
wordingRows.value.filter((item) => {
|
||||
if (wordingModule.value && item.module !== wordingModule.value) return false;
|
||||
@@ -383,6 +415,36 @@ function canAny(permissionKey: string | string[]) {
|
||||
return Array.isArray(permissionKey) ? permissionKey.some((key) => can(key)) : can(permissionKey);
|
||||
}
|
||||
|
||||
function toggleDataToolScope(values: DataToolScope[], scope: DataToolScope) {
|
||||
const nextValues = new Set(values);
|
||||
if (nextValues.has(scope)) {
|
||||
nextValues.delete(scope);
|
||||
} else {
|
||||
nextValues.add(scope);
|
||||
}
|
||||
return normalizeDataToolScopes([...nextValues]);
|
||||
}
|
||||
|
||||
function normalizeDataToolScopes(scopes: DataToolScope[]) {
|
||||
const nextValues = new Set(scopes);
|
||||
if (nextValues.has('items')) {
|
||||
nextValues.add('recipes');
|
||||
}
|
||||
return dataToolScopeKeys.filter((item) => nextValues.has(item));
|
||||
}
|
||||
|
||||
function dataToolScopeLocked(values: DataToolScope[], scope: DataToolScope) {
|
||||
return scope === 'recipes' && values.includes('items');
|
||||
}
|
||||
|
||||
function toggleExportScope(scope: DataToolScope) {
|
||||
selectedExportScopes.value = toggleDataToolScope(selectedExportScopes.value, scope);
|
||||
}
|
||||
|
||||
function toggleWipeScope(scope: DataToolScope) {
|
||||
selectedWipeScopes.value = toggleDataToolScope(selectedWipeScopes.value, scope);
|
||||
}
|
||||
|
||||
function dragSortLabel(name: string) {
|
||||
return t('pages.admin.dragSort', { name });
|
||||
}
|
||||
@@ -928,6 +990,10 @@ async function loadRateLimitSettings() {
|
||||
resetRateLimitForm(rateLimitSettings.value);
|
||||
}
|
||||
|
||||
async function loadDataToolsSummary() {
|
||||
dataToolsSummary.value = await api.dataToolsSummary();
|
||||
}
|
||||
|
||||
async function reloadWordings() {
|
||||
await run(loadWordings);
|
||||
}
|
||||
@@ -1051,6 +1117,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
||||
if (activeTab.value === 'languages') await loadLanguages();
|
||||
if (activeTab.value === 'wordings') await loadWordings();
|
||||
if (activeTab.value === 'aiModeration') await loadAiModerationSettings();
|
||||
if (activeTab.value === 'dataTools') await loadDataToolsSummary();
|
||||
if (activeTab.value === 'checklist') await loadChecklist();
|
||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||
if (activeTab.value === 'items') await loadItems();
|
||||
@@ -1155,6 +1222,117 @@ async function removeHabitat(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
function downloadDataToolsBundle(bundle: DataToolsBundle) {
|
||||
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `pokopia-data-${bundle.exportedAt.slice(0, 10)}.json`;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function exportDataTools() {
|
||||
const scopes = normalizeDataToolScopes(selectedExportScopes.value);
|
||||
if (!scopes.length) {
|
||||
message.value = t('pages.admin.dataToolSelectScope');
|
||||
return;
|
||||
}
|
||||
selectedExportScopes.value = scopes;
|
||||
|
||||
await run(async () => {
|
||||
const bundle = await api.exportDataTools(scopes);
|
||||
downloadDataToolsBundle(bundle);
|
||||
});
|
||||
}
|
||||
|
||||
function openWipeDataTools() {
|
||||
const scopes = normalizeDataToolScopes(selectedWipeScopes.value);
|
||||
if (!scopes.length) {
|
||||
message.value = t('pages.admin.dataToolSelectScope');
|
||||
return;
|
||||
}
|
||||
selectedWipeScopes.value = scopes;
|
||||
dataToolConfirmText.value = '';
|
||||
dataToolWipeModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeWipeDataToolsModal() {
|
||||
dataToolWipeModalOpen.value = false;
|
||||
dataToolConfirmText.value = '';
|
||||
}
|
||||
|
||||
async function confirmWipeDataTools() {
|
||||
if (dataToolConfirmText.value !== 'WIPE') {
|
||||
return;
|
||||
}
|
||||
|
||||
await run(async () => {
|
||||
dataToolsSummary.value = await api.wipeDataTools(normalizeDataToolScopes(selectedWipeScopes.value));
|
||||
selectedWipeScopes.value = [];
|
||||
closeWipeDataToolsModal();
|
||||
});
|
||||
}
|
||||
|
||||
function validDataToolsBundle(value: unknown): value is DataToolsBundle {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const bundle = value as Partial<DataToolsBundle>;
|
||||
return (
|
||||
bundle.version === 1 &&
|
||||
Array.isArray(bundle.scopes) &&
|
||||
bundle.scopes.length > 0 &&
|
||||
bundle.scopes.every((scope) => dataToolScopeKeys.includes(scope)) &&
|
||||
Boolean(bundle.data) &&
|
||||
typeof bundle.data === 'object' &&
|
||||
!Array.isArray(bundle.data)
|
||||
);
|
||||
}
|
||||
|
||||
async function selectImportDataToolsFile(event: Event) {
|
||||
const input = event.target instanceof HTMLInputElement ? event.target : null;
|
||||
const file = input?.files?.[0];
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bundle = JSON.parse(await file.text()) as unknown;
|
||||
if (!validDataToolsBundle(bundle)) {
|
||||
message.value = t('pages.admin.dataToolInvalidBundle');
|
||||
return;
|
||||
}
|
||||
pendingImportBundle.value = { ...bundle, scopes: normalizeDataToolScopes(bundle.scopes) };
|
||||
dataToolConfirmText.value = '';
|
||||
dataToolImportModalOpen.value = true;
|
||||
} catch {
|
||||
message.value = t('pages.admin.dataToolInvalidBundle');
|
||||
}
|
||||
}
|
||||
|
||||
function closeImportDataToolsModal() {
|
||||
dataToolImportModalOpen.value = false;
|
||||
pendingImportBundle.value = null;
|
||||
dataToolConfirmText.value = '';
|
||||
}
|
||||
|
||||
async function confirmImportDataTools() {
|
||||
if (!pendingImportBundle.value || dataToolConfirmText.value !== 'IMPORT') {
|
||||
return;
|
||||
}
|
||||
|
||||
await run(async () => {
|
||||
dataToolsSummary.value = await api.importDataTools(pendingImportBundle.value as DataToolsBundle);
|
||||
closeImportDataToolsModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function removeRole(id: number) {
|
||||
await run(async () => {
|
||||
await api.deleteRole(id);
|
||||
@@ -1425,6 +1603,78 @@ onMounted(() => {
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'dataTools'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.dataTools') }}</h2>
|
||||
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy" @click="run(loadDataToolsSummary)">
|
||||
<Icon :icon="iconAdmin" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.admin.dataToolRefresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="data-tool-grid">
|
||||
<section class="data-tool-panel" :aria-label="t('pages.admin.dataToolExport')">
|
||||
<div class="data-tool-panel__header">
|
||||
<h3>{{ t('pages.admin.dataToolExport') }}</h3>
|
||||
<button type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy || !can('admin.data.export')" @click="exportDataTools">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.admin.dataToolExportButton') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="data-tool-scope-list">
|
||||
<label v-for="option in dataToolScopeOptions" :key="`export-${option.value}`" class="data-tool-scope">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedExportScopes.includes(option.value)"
|
||||
:disabled="busy || !can('admin.data.export') || dataToolScopeLocked(selectedExportScopes, option.value)"
|
||||
@change="toggleExportScope(option.value)"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
<span class="config-flag">{{ option.count }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolUploadsNote') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="data-tool-panel" :aria-label="t('pages.admin.dataToolImport')">
|
||||
<div class="data-tool-panel__header">
|
||||
<h3>{{ t('pages.admin.dataToolImport') }}</h3>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="data-tools-import-file">{{ t('pages.admin.dataToolImportFile') }}</label>
|
||||
<input id="data-tools-import-file" type="file" accept="application/json,.json" :disabled="busy || !can('admin.data.import')" @change="selectImportDataToolsFile" />
|
||||
</div>
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolImportMode') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="data-tool-panel data-tool-panel--danger" :aria-label="t('pages.admin.dataToolWipe')">
|
||||
<div class="data-tool-panel__header">
|
||||
<h3>{{ t('pages.admin.dataToolWipe') }}</h3>
|
||||
<button type="button" class="ui-button ui-button--red ui-button--small" :disabled="busy || !can('admin.data.import')" @click="openWipeDataTools">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.admin.dataToolWipeButton') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="data-tool-scope-list">
|
||||
<label v-for="option in dataToolScopeOptions" :key="`wipe-${option.value}`" class="data-tool-scope">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedWipeScopes.includes(option.value)"
|
||||
:disabled="busy || !can('admin.data.import') || dataToolScopeLocked(selectedWipeScopes, option.value)"
|
||||
@change="toggleWipeScope(option.value)"
|
||||
/>
|
||||
<span>{{ option.label }}</span>
|
||||
<span class="config-flag">{{ option.count }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolDependencyNote') }}</p>
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolReplaceNote') }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.config') }}</h2>
|
||||
@@ -1802,6 +2052,44 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal v-if="dataToolImportModalOpen" :title="t('pages.admin.dataToolImport')" :close-label="t('common.close')" @close="closeImportDataToolsModal">
|
||||
<form id="admin-data-tool-import-form" class="modal-edit-form" @submit.prevent="confirmImportDataTools">
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolImportConfirm', { scopes: importScopeLabels }) }}</p>
|
||||
<div class="field">
|
||||
<label for="data-tool-import-confirm">{{ t('pages.admin.dataToolConfirmImport') }}</label>
|
||||
<input id="data-tool-import-confirm" v-model="dataToolConfirmText" autocomplete="off" required />
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="link-button" :disabled="busy" @click="closeImportDataToolsModal">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" form="admin-data-tool-import-form" class="link-button" :disabled="busy || dataToolConfirmText !== 'IMPORT'">
|
||||
<Icon :icon="iconUpload" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.admin.dataToolImportButton') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="dataToolWipeModalOpen" :title="t('pages.admin.dataToolWipe')" :close-label="t('common.close')" @close="closeWipeDataToolsModal">
|
||||
<form id="admin-data-tool-wipe-form" class="modal-edit-form" @submit.prevent="confirmWipeDataTools">
|
||||
<p class="meta-line">{{ t('pages.admin.dataToolWipeConfirm', { scopes: wipeScopeLabels }) }}</p>
|
||||
<div class="field">
|
||||
<label for="data-tool-wipe-confirm">{{ t('pages.admin.dataToolConfirmWipe') }}</label>
|
||||
<input id="data-tool-wipe-confirm" v-model="dataToolConfirmText" autocomplete="off" required />
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<button type="button" class="link-button" :disabled="busy" @click="closeWipeDataToolsModal">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" form="admin-data-tool-wipe-form" class="link-button" :disabled="busy || dataToolConfirmText !== 'WIPE'">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.admin.dataToolWipeButton') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="userRoleModalOpen" :title="userRoleModalTitle" :close-label="t('common.close')" size="wide" @close="closeUserRoleModal">
|
||||
<form id="admin-user-roles-form" class="modal-edit-form" @submit.prevent="saveUserRoles">
|
||||
<div v-if="editingUser" class="access-modal-heading">
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PokeBallMark from '../components/PokeBallMark.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import {
|
||||
iconAction,
|
||||
iconAutomation,
|
||||
iconChevronRight,
|
||||
iconChecklist,
|
||||
iconClothes,
|
||||
iconDish,
|
||||
iconDreamIsland,
|
||||
iconEvent,
|
||||
iconExternal,
|
||||
iconGitCommit,
|
||||
iconHabitat,
|
||||
iconItem,
|
||||
iconLife,
|
||||
iconPokemon,
|
||||
iconRecipe
|
||||
} from '../icons';
|
||||
import { api, type ProjectUpdateCommit, type ProjectUpdates } from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const projectCommitPageSize = 5;
|
||||
const projectUpdates = ref<ProjectUpdates | null>(null);
|
||||
const projectUpdatesLoading = ref(true);
|
||||
const projectCommits = ref<ProjectUpdateCommit[]>([]);
|
||||
|
||||
const primarySections = computed(() => [
|
||||
{ key: 'pokemon', to: '/pokemon', icon: iconPokemon },
|
||||
@@ -42,6 +51,17 @@ const futureSections = computed(() => [
|
||||
{ key: 'clothes', to: '/clothes', icon: iconClothes }
|
||||
]);
|
||||
|
||||
const latestReleases = computed(() => projectUpdates.value?.releases.slice(0, 3) ?? []);
|
||||
const showProjectUpdates = computed(
|
||||
() => projectUpdatesLoading.value || projectCommits.value.length > 0 || latestReleases.value.length > 0
|
||||
);
|
||||
const showProjectUpdatesViewAll = computed(() => projectCommits.value.length > 0 || latestReleases.value.length > 0);
|
||||
const repositoryUpdatedAt = computed(() => formatDateTime(projectUpdates.value?.repository.updatedAt ?? null));
|
||||
|
||||
onMounted(() => {
|
||||
void loadProjectUpdates();
|
||||
});
|
||||
|
||||
function sectionTitleKey(key: string) {
|
||||
return `pages.home.sections.${key}.title`;
|
||||
}
|
||||
@@ -49,6 +69,34 @@ function sectionTitleKey(key: string) {
|
||||
function sectionDescriptionKey(key: string) {
|
||||
return `pages.home.sections.${key}.description`;
|
||||
}
|
||||
|
||||
async function loadProjectUpdates(): Promise<void> {
|
||||
projectUpdatesLoading.value = true;
|
||||
try {
|
||||
const updates = await api.projectUpdates({ limit: projectCommitPageSize });
|
||||
projectUpdates.value = updates;
|
||||
projectCommits.value = updates.commits.items;
|
||||
} catch {
|
||||
projectUpdates.value = null;
|
||||
projectCommits.value = [];
|
||||
} finally {
|
||||
projectUpdatesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null): string {
|
||||
if (!value) return '';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -145,6 +193,84 @@ function sectionDescriptionKey(key: string) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showProjectUpdates" class="home-section home-project-updates" aria-labelledby="home-project-updates-title">
|
||||
<div class="home-section__header">
|
||||
<span class="page-kicker">{{ t('pages.home.projectUpdatesKicker') }}</span>
|
||||
<h2 id="home-project-updates-title">{{ t('pages.home.projectUpdatesTitle') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="home-project-updates__panel">
|
||||
<div v-if="projectUpdates" class="home-project-updates__repo">
|
||||
<span class="home-project-updates__repo-label">{{ t('pages.home.projectUpdatesRepo') }}</span>
|
||||
<a :href="projectUpdates.repository.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconGitCommit" class="ui-icon" aria-hidden="true" />
|
||||
{{ projectUpdates.repository.fullName }}
|
||||
</a>
|
||||
<span v-if="repositoryUpdatedAt" class="home-project-updates__updated">
|
||||
{{ t('pages.home.projectUpdatesUpdatedAt', { date: repositoryUpdatedAt }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="projectUpdatesLoading" class="home-project-updates__skeleton">
|
||||
<Skeleton width="42%" />
|
||||
<Skeleton width="76%" />
|
||||
<Skeleton width="64%" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="projectUpdates" class="home-project-updates__content">
|
||||
<div v-if="latestReleases.length" class="home-project-updates__group">
|
||||
<h3>{{ t('pages.home.projectUpdatesReleases') }}</h3>
|
||||
<ol class="home-project-updates__list">
|
||||
<li v-for="release in latestReleases" :key="release.tagName" class="home-project-updates__item">
|
||||
<div class="home-project-updates__commit">
|
||||
<div class="home-project-updates__title">
|
||||
<span class="home-project-updates__sha">{{ release.tagName }}</span>
|
||||
<strong>{{ release.name }}</strong>
|
||||
</div>
|
||||
<div v-if="release.publishedAt" class="home-project-updates__meta">
|
||||
<time :datetime="release.publishedAt">{{ formatDateTime(release.publishedAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<a class="ui-button ui-button--ghost home-project-updates__link" :href="release.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.home.projectUpdatesViewRelease') }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="projectCommits.length" class="home-project-updates__group">
|
||||
<h3>{{ t('pages.home.projectUpdatesCommits') }}</h3>
|
||||
<ol class="home-project-updates__list">
|
||||
<li v-for="commit in projectCommits" :key="commit.sha" class="home-project-updates__item">
|
||||
<div class="home-project-updates__commit">
|
||||
<div class="home-project-updates__title">
|
||||
<span class="home-project-updates__sha">{{ commit.shortSha }}</span>
|
||||
<strong>{{ commit.title }}</strong>
|
||||
</div>
|
||||
<div class="home-project-updates__meta">
|
||||
<span>{{ commit.authorName }}</span>
|
||||
<time :datetime="commit.createdAt">{{ formatDateTime(commit.createdAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<a class="ui-button ui-button--ghost home-project-updates__link" :href="commit.url" target="_blank" rel="noreferrer">
|
||||
<Icon :icon="iconExternal" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.home.projectUpdatesViewCommit') }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div v-if="showProjectUpdatesViewAll" class="home-project-updates__actions">
|
||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/project-updates">
|
||||
<Icon :icon="iconChevronRight" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.home.projectUpdatesViewAll') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="home-section" aria-labelledby="home-future-title">
|
||||
<div class="home-section__header">
|
||||
<span class="page-kicker">{{ t('pages.home.futureKicker') }}</span>
|
||||
|
||||
@@ -79,6 +79,8 @@ function defaultPokemonStats(): PokemonStats {
|
||||
}
|
||||
|
||||
const pokemonForm = ref({
|
||||
dataId: null as number | null,
|
||||
dataIdentifier: '',
|
||||
id: '',
|
||||
isEventItem: false,
|
||||
name: '',
|
||||
@@ -257,8 +259,22 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine
|
||||
}
|
||||
|
||||
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
|
||||
const routePokemonId = Number(routeId.value);
|
||||
if (
|
||||
isEditing.value &&
|
||||
!pokemonForm.value.isEventItem &&
|
||||
Number.isInteger(routePokemonId) &&
|
||||
routePokemonId > 0 &&
|
||||
fetchedPokemon.id !== routePokemonId
|
||||
) {
|
||||
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
|
||||
return false;
|
||||
}
|
||||
|
||||
pokemonForm.value = {
|
||||
...pokemonForm.value,
|
||||
dataId: fetchedPokemon.id,
|
||||
dataIdentifier: fetchedPokemon.identifier,
|
||||
id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id,
|
||||
name: fetchedPokemon.name,
|
||||
genus: fetchedPokemon.genus,
|
||||
@@ -295,6 +311,8 @@ async function loadEditor() {
|
||||
if (isEditing.value) {
|
||||
const pokemon = await api.pokemonDetail(routeId.value);
|
||||
pokemonForm.value = {
|
||||
dataId: pokemon.dataId ?? null,
|
||||
dataIdentifier: pokemon.dataIdentifier ?? '',
|
||||
id: String(pokemon.displayId),
|
||||
isEventItem: pokemon.isEventItem,
|
||||
name: pokemon.baseName ?? pokemon.name,
|
||||
@@ -678,6 +696,8 @@ async function savePokemon() {
|
||||
|
||||
try {
|
||||
const payload: PokemonPayload = {
|
||||
dataId: pokemonForm.value.dataId,
|
||||
dataIdentifier: pokemonForm.value.dataIdentifier,
|
||||
displayId: pokemonIdForSave(),
|
||||
isEventItem: pokemonForm.value.isEventItem,
|
||||
name: pokemonNameForSave(),
|
||||
|
||||
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',
|
||||
communityKicker: 'Daily & Community',
|
||||
communityTitle: 'Follow daily tasks and community updates',
|
||||
projectUpdatesKicker: 'Project Updates',
|
||||
projectUpdatesTitle: 'Latest site changes',
|
||||
projectUpdatesRepo: 'Source repository',
|
||||
projectUpdatesUpdatedAt: 'Updated {date}',
|
||||
projectUpdatesCommits: 'Recent commits',
|
||||
projectUpdatesReleases: 'Releases',
|
||||
projectUpdatesViewCommit: 'View commit',
|
||||
projectUpdatesViewRelease: 'View release',
|
||||
projectUpdatesViewAll: 'View all',
|
||||
futureKicker: 'More Sections',
|
||||
futureTitle: 'Planned wiki areas',
|
||||
sections: {
|
||||
@@ -210,6 +219,24 @@ export const systemWordingMessages = {
|
||||
}
|
||||
}
|
||||
},
|
||||
projectUpdates: {
|
||||
kicker: 'Project Updates',
|
||||
title: 'Project Updates',
|
||||
subtitle: 'Follow public site changes from the Pokopia Wiki source repository.',
|
||||
sourceRepository: 'Source repository',
|
||||
updatedAt: 'Updated {date}',
|
||||
openRepository: 'Open repository',
|
||||
commits: 'Commits',
|
||||
releases: 'Releases',
|
||||
viewCommit: 'View commit',
|
||||
viewRelease: 'View release',
|
||||
expandMessage: 'Expand',
|
||||
collapseMessage: 'Collapse',
|
||||
commitMessage: 'Commit message',
|
||||
loading: 'Loading project updates',
|
||||
retry: 'Retry',
|
||||
empty: 'No commits yet'
|
||||
},
|
||||
legal: {
|
||||
lastUpdated: 'Last updated: May 3, 2026',
|
||||
sourceLinks: 'Source and reference links',
|
||||
@@ -805,6 +832,30 @@ export const systemWordingMessages = {
|
||||
itemList: 'Item list',
|
||||
recipeList: 'Recipe list',
|
||||
habitatList: 'Habitat list',
|
||||
dataTools: 'Data tools',
|
||||
dataToolRefresh: 'Refresh',
|
||||
dataToolExport: 'Export data',
|
||||
dataToolExportButton: 'Export JSON',
|
||||
dataToolImport: 'Import data',
|
||||
dataToolImportButton: 'Import',
|
||||
dataToolImportFile: 'Data bundle',
|
||||
dataToolImportMode: 'Import replaces the scopes included in the bundle.',
|
||||
dataToolWipe: 'Wipe data',
|
||||
dataToolWipeButton: 'Wipe',
|
||||
dataToolSelectScope: 'Select at least one data scope.',
|
||||
dataToolInvalidBundle: 'Data bundle is invalid.',
|
||||
dataToolImportConfirm: 'Import will replace: {scopes}.',
|
||||
dataToolWipeConfirm: 'Wipe will delete: {scopes}.',
|
||||
dataToolConfirmImport: 'Type IMPORT to confirm',
|
||||
dataToolConfirmWipe: 'Type WIPE to confirm',
|
||||
dataToolDependencyNote: 'Items include Recipes because recipes depend on items.',
|
||||
dataToolReplaceNote: 'Related records, translations, edit history, image history, and discussions are included.',
|
||||
dataToolUploadsNote: 'Uploaded files are not included in JSON exports.',
|
||||
dataToolScopePokemon: 'Pokemon',
|
||||
dataToolScopeHabitats: 'Habitats',
|
||||
dataToolScopeItems: 'Items',
|
||||
dataToolScopeRecipes: 'Recipes',
|
||||
dataToolScopeChecklist: 'Daily CheckList',
|
||||
languages: 'Languages',
|
||||
newConfig: 'New {name}',
|
||||
editConfig: 'Edit {name}',
|
||||
@@ -1027,6 +1078,10 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: 'Pokemon identifier is required',
|
||||
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
||||
pokemonDataNotFound: 'Pokemon data was not found',
|
||||
pokemonDataIdMismatch: 'Pokemon data ID does not match this Pokemon',
|
||||
dataToolScopeRequired: 'Select at least one data scope',
|
||||
dataToolScopeInvalid: 'Data scope is invalid',
|
||||
dataToolBundleInvalid: 'Data bundle is invalid',
|
||||
pokemonImagePathInvalid: 'Pokemon image path is invalid',
|
||||
imagePathInvalid: 'Image path is invalid',
|
||||
imageUploadRequired: 'Please select an image',
|
||||
@@ -1263,6 +1318,15 @@ export const systemWordingMessages = {
|
||||
wikiTitle: '浏览游戏资料',
|
||||
communityKicker: '每日与社区',
|
||||
communityTitle: '查看每日任务和社区更新',
|
||||
projectUpdatesKicker: '项目更新',
|
||||
projectUpdatesTitle: '最近站点改动',
|
||||
projectUpdatesRepo: '源码仓库',
|
||||
projectUpdatesUpdatedAt: '更新于 {date}',
|
||||
projectUpdatesCommits: '最近提交',
|
||||
projectUpdatesReleases: '发布版本',
|
||||
projectUpdatesViewCommit: '查看提交',
|
||||
projectUpdatesViewRelease: '查看发布',
|
||||
projectUpdatesViewAll: '查看全部',
|
||||
futureKicker: '更多分区',
|
||||
futureTitle: '规划中的 Wiki 区域',
|
||||
sections: {
|
||||
@@ -1316,6 +1380,24 @@ export const systemWordingMessages = {
|
||||
}
|
||||
}
|
||||
},
|
||||
projectUpdates: {
|
||||
kicker: '项目更新',
|
||||
title: '项目更新',
|
||||
subtitle: '查看 Pokopia Wiki 源码仓库中的公开站点改动。',
|
||||
sourceRepository: '源码仓库',
|
||||
updatedAt: '更新于 {date}',
|
||||
openRepository: '打开仓库',
|
||||
commits: '提交记录',
|
||||
releases: '发布版本',
|
||||
viewCommit: '查看提交',
|
||||
viewRelease: '查看发布',
|
||||
expandMessage: '展开',
|
||||
collapseMessage: '收起',
|
||||
commitMessage: 'Commit Message',
|
||||
loading: '正在加载项目更新',
|
||||
retry: '重试',
|
||||
empty: '暂无提交'
|
||||
},
|
||||
legal: {
|
||||
lastUpdated: '最后更新:2026年5月3日',
|
||||
sourceLinks: '来源与参考链接',
|
||||
@@ -1891,6 +1973,30 @@ export const systemWordingMessages = {
|
||||
itemList: '物品列表',
|
||||
recipeList: '材料单列表',
|
||||
habitatList: '栖息地列表',
|
||||
dataTools: '数据工具',
|
||||
dataToolRefresh: '刷新',
|
||||
dataToolExport: '导出数据',
|
||||
dataToolExportButton: '导出 JSON',
|
||||
dataToolImport: '导入数据',
|
||||
dataToolImportButton: '导入',
|
||||
dataToolImportFile: '数据包',
|
||||
dataToolImportMode: '导入会替换数据包内包含的范围。',
|
||||
dataToolWipe: '清空数据',
|
||||
dataToolWipeButton: '清空',
|
||||
dataToolSelectScope: '请至少选择一个数据范围。',
|
||||
dataToolInvalidBundle: '数据包不合法。',
|
||||
dataToolImportConfirm: '导入将替换:{scopes}。',
|
||||
dataToolWipeConfirm: '清空将删除:{scopes}。',
|
||||
dataToolConfirmImport: '输入 IMPORT 确认',
|
||||
dataToolConfirmWipe: '输入 WIPE 确认',
|
||||
dataToolDependencyNote: '物品会连同材料单一起处理,因为材料单依赖物品。',
|
||||
dataToolReplaceNote: '关联记录、翻译、编辑历史、图片历史和讨论会一并处理。',
|
||||
dataToolUploadsNote: 'JSON 导出不包含上传文件本身。',
|
||||
dataToolScopePokemon: 'Pokemon',
|
||||
dataToolScopeHabitats: '栖息地',
|
||||
dataToolScopeItems: '物品',
|
||||
dataToolScopeRecipes: '材料单',
|
||||
dataToolScopeChecklist: '每日 CheckList',
|
||||
languages: '语言',
|
||||
newConfig: '新增{name}',
|
||||
editConfig: '编辑{name}',
|
||||
@@ -2113,6 +2219,10 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: '请输入 Pokemon 标识',
|
||||
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
||||
pokemonDataNotFound: '未找到 Pokemon 数据',
|
||||
pokemonDataIdMismatch: 'Pokemon 数据 ID 与当前 Pokemon 不一致',
|
||||
dataToolScopeRequired: '请至少选择一个数据范围',
|
||||
dataToolScopeInvalid: '数据范围不合法',
|
||||
dataToolBundleInvalid: '数据包不合法',
|
||||
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
||||
imagePathInvalid: '图片路径不合法',
|
||||
imageUploadRequired: '请选择图片',
|
||||
|
||||
Reference in New Issue
Block a user