Compare commits
7 Commits
e8e20539c9
...
36e10a06b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 36e10a06b0 | |||
| 4a42756e2e | |||
| 97f06794a8 | |||
| 874ecc5625 | |||
| cf0ae566c0 | |||
| 475e3577dd | |||
| 976a2a2482 |
@@ -3,4 +3,3 @@
|
|||||||
**/dist
|
**/dist
|
||||||
**/*.log
|
**/*.log
|
||||||
**/.env
|
**/.env
|
||||||
frontend
|
|
||||||
|
|||||||
58
DESIGN.md
58
DESIGN.md
@@ -6,7 +6,7 @@
|
|||||||
- 所有人都可以浏览 Wiki 内容。
|
- 所有人都可以浏览 Wiki 内容。
|
||||||
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
|
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
|
||||||
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||||
- 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。
|
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
@@ -68,6 +68,23 @@
|
|||||||
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
||||||
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
||||||
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。
|
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。
|
||||||
|
- 系统级文案独立于实体翻译,不进入 `entity_translations`。
|
||||||
|
- 系统级文案 key 由代码 catalog 维护,覆盖前端界面、后端错误提示和认证邮件模板。
|
||||||
|
- 系统级文案值存储在 `system_wording_values`,key 元信息存储在 `system_wording_keys`:
|
||||||
|
- `key`
|
||||||
|
- `module`
|
||||||
|
- `surface`:`frontend` / `backend` / `email`
|
||||||
|
- `description`
|
||||||
|
- `placeholders`
|
||||||
|
- `enabled`
|
||||||
|
- `locale`
|
||||||
|
- `value`
|
||||||
|
- 后端启动时同步代码 catalog,只补充缺失 key 和初始 value,不覆盖管理员已维护的 value。
|
||||||
|
- 系统级文案回退顺序为:请求语言 value -> 默认语言 value -> 代码内置 fallback。
|
||||||
|
- 系统级文案中的占位符必须与默认文案一致,例如 `{count}`、`{name}`;保存时校验,避免运行时插值失败。
|
||||||
|
- 前端组件必须通过 Vue I18n key 读取系统文案,不直接写用户可见硬编码文案;后续新增模块必须先在 catalog 中注册 wording key。
|
||||||
|
- 后端返回给前端的 user-facing 错误信息必须通过系统文案解析,不返回 token/hash、内部调试字段或未本地化的内部错误文本。
|
||||||
|
- 管理入口提供 System wordings 维护能力,可按语言、模块、端和缺失状态查看并编辑系统级文案。
|
||||||
|
|
||||||
## 用户与认证
|
## 用户与认证
|
||||||
|
|
||||||
@@ -85,12 +102,24 @@
|
|||||||
- 验证邮件包含一次性验证链接。
|
- 验证邮件包含一次性验证链接。
|
||||||
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
||||||
- 只有邮箱已验证的用户可以登录。
|
- 只有邮箱已验证的用户可以登录。
|
||||||
|
- 用户可请求重置密码:
|
||||||
|
- 重置请求只接收邮箱,并始终返回泛化成功信息,避免暴露邮箱是否已注册。
|
||||||
|
- 重置邮件包含一次性重置链接。
|
||||||
|
- 重置 token 只保存 hash,并带过期时间和使用状态。
|
||||||
|
- 密码重置成功后不自动登录,并删除该用户已有 session。
|
||||||
|
- 登录页提供 Remember me:
|
||||||
|
- 未勾选时前端将登录 token 保存在 `sessionStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 1 天。
|
||||||
|
- 勾选时前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 30 天。
|
||||||
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
||||||
- 前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`。
|
|
||||||
- 用户可退出登录,退出时删除对应 session。
|
- 用户可退出登录,退出时删除对应 session。
|
||||||
- 对外用户字段只包含必要信息:
|
- 对外用户字段只包含必要信息:
|
||||||
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||||
- 编辑署名:`id`、`displayName`
|
- 编辑署名:`id`、`displayName`
|
||||||
|
- User Profile:
|
||||||
|
- 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名。
|
||||||
|
- 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改。
|
||||||
|
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
||||||
|
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
||||||
|
|
||||||
## Community 编辑与审计
|
## Community 编辑与审计
|
||||||
|
|
||||||
@@ -215,19 +244,28 @@ Pokemon 可配置:
|
|||||||
Pokemon 编辑表单使用标签页组织字段:
|
Pokemon 编辑表单使用标签页组织字段:
|
||||||
|
|
||||||
- 编辑表单提供 Fetch data 功能:
|
- 编辑表单提供 Fetch data 功能:
|
||||||
- 已验证用户可输入 data identifier 或 Pokemon ID,从仓库 `data/` CSV 查询基础资料并填入当前表单。
|
- 已验证用户可输入 data identifier 或 Pokemon ID,从同一个搜索输入查询基础资料或图片候选。
|
||||||
|
- Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。
|
||||||
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
||||||
|
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
||||||
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
||||||
- Fetch 只填入 CSV 可提供的字段:ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
- Fetch 只填入 CSV 可提供的字段:ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
||||||
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||||
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。
|
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。
|
||||||
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
||||||
- Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。
|
- Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。
|
||||||
|
- 编辑表单提供 Pokemon 图片选择功能:
|
||||||
|
- 已验证用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。
|
||||||
|
- 图片候选只使用 `/sprites/pokemon/...` 相对路径,后端按固定资源族生成候选并用 `HEAD` 校验存在性;不保存任意外部 URL。
|
||||||
|
- 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||||
|
- 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。
|
||||||
|
- Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。
|
||||||
- 基础标签页:
|
- 基础标签页:
|
||||||
- 第一行:ID、名称
|
- 第一行:ID、名称
|
||||||
- 第二行:喜欢的环境、特长
|
- 第二行:喜欢的环境、特长
|
||||||
- 第三行:喜欢的东西
|
- 第三行:喜欢的东西
|
||||||
- 特长掉落物品随已选择且支持掉落物的特长显示
|
- 特长掉落物品随已选择且支持掉落物的特长显示
|
||||||
|
- Pokemon 图片选择区
|
||||||
- Advance 标签页:
|
- Advance 标签页:
|
||||||
- 第一行:Genus
|
- 第一行:Genus
|
||||||
- 第二行:Details
|
- 第二行:Details
|
||||||
@@ -246,14 +284,17 @@ Pokemon 列表功能:
|
|||||||
- 满足任意条件
|
- 满足任意条件
|
||||||
- 满足全部条件
|
- 满足全部条件
|
||||||
- 按自定义排序展示
|
- 按自定义排序展示
|
||||||
|
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
||||||
|
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
||||||
|
|
||||||
Pokemon 详情页展示:
|
Pokemon 详情页展示:
|
||||||
|
|
||||||
- 基本信息
|
- 基本信息
|
||||||
|
- 已配置图片时,详情主内容在六维 Stats 右侧展示正方形居中的 Pokédex 风格图片;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情;未配置图片时不显示图片区。
|
||||||
- 主内容顶部按以下布局展示:
|
- 主内容顶部按以下布局展示:
|
||||||
- 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容
|
- 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容
|
||||||
- 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
|
- 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
|
||||||
- 右侧:六维 Stats
|
- 右侧:六维 Stats;已配置图片时图片展示在 Stats 右侧
|
||||||
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
||||||
- 特长
|
- 特长
|
||||||
- 特长掉落物品
|
- 特长掉落物品
|
||||||
@@ -462,6 +503,7 @@ API 暴露边界:
|
|||||||
- UI 风格以 `DesignGuidelines.html` 为准。
|
- UI 风格以 `DesignGuidelines.html` 为准。
|
||||||
- 页面结构以 `AppShell`、`PageHeader`、列表、详情区和管理区为核心。
|
- 页面结构以 `AppShell`、`PageHeader`、列表、详情区和管理区为核心。
|
||||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||||
|
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||||
- 页面级分类、筛选或辅助内容切换使用 Tabs,避免在内容页继续增加侧边栏。
|
- 页面级分类、筛选或辅助内容切换使用 Tabs,避免在内容页继续增加侧边栏。
|
||||||
- 导航和主要操作使用图标增强识别。
|
- 导航和主要操作使用图标增强识别。
|
||||||
- 数据加载状态使用 Skeleton,避免裸文本 loading。
|
- 数据加载状态使用 Skeleton,避免裸文本 loading。
|
||||||
@@ -486,6 +528,7 @@ API 暴露边界:
|
|||||||
公开浏览 API:
|
公开浏览 API:
|
||||||
|
|
||||||
- `GET /api/languages`
|
- `GET /api/languages`
|
||||||
|
- `GET /api/system-wordings`
|
||||||
- `GET /api/options`
|
- `GET /api/options`
|
||||||
- `GET /api/daily-checklist`
|
- `GET /api/daily-checklist`
|
||||||
- `GET /api/pokemon`
|
- `GET /api/pokemon`
|
||||||
@@ -504,7 +547,10 @@ API 暴露边界:
|
|||||||
- `POST /api/auth/register`
|
- `POST /api/auth/register`
|
||||||
- `POST /api/auth/verify-email`
|
- `POST /api/auth/verify-email`
|
||||||
- `POST /api/auth/login`
|
- `POST /api/auth/login`
|
||||||
|
- `POST /api/auth/request-password-reset`
|
||||||
|
- `POST /api/auth/reset-password`
|
||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
|
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
|
||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
|
|
||||||
已验证用户编辑 API:
|
已验证用户编辑 API:
|
||||||
@@ -512,6 +558,7 @@ API 暴露边界:
|
|||||||
- Pokemon、栖息地、物品、材料单的创建、更新、删除。
|
- Pokemon、栖息地、物品、材料单的创建、更新、删除。
|
||||||
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id`、`identifier`、`name`。
|
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id`、`identifier`、`name`。
|
||||||
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。
|
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。
|
||||||
|
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要已验证用户;只返回 `id`、`identifier` 和图片候选列表。
|
||||||
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
|
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
|
||||||
- `POST /api/life-posts`
|
- `POST /api/life-posts`
|
||||||
- `PUT /api/life-posts/:id`
|
- `PUT /api/life-posts/:id`
|
||||||
@@ -530,6 +577,9 @@ API 暴露边界:
|
|||||||
- 每日 CheckList 的创建、更新、删除、排序。
|
- 每日 CheckList 的创建、更新、删除、排序。
|
||||||
- 全局配置项的创建、更新、删除、排序。
|
- 全局配置项的创建、更新、删除、排序。
|
||||||
- 语言的创建、更新、删除、排序。
|
- 语言的创建、更新、删除、排序。
|
||||||
|
- 系统级文案的查看和更新。
|
||||||
|
- `GET /api/admin/system-wordings`
|
||||||
|
- `PUT /api/admin/system-wordings/:key`
|
||||||
- Pokemon、物品、材料单、栖息地的列表排序。
|
- Pokemon、物品、材料单、栖息地的列表排序。
|
||||||
|
|
||||||
## 开发与验证
|
## 开发与验证
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app/backend
|
||||||
COPY backend/package.json ./
|
COPY backend/package.json ./
|
||||||
RUN corepack enable && pnpm install
|
RUN corepack enable && pnpm install
|
||||||
COPY backend/. .
|
COPY backend/. .
|
||||||
COPY data ./data
|
COPY data /app/data
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY system-wordings.ts /app/system-wordings.ts
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["pnpm", "run", "start"]
|
CMD ["pnpm", "run", "start"]
|
||||||
|
|||||||
@@ -85,6 +85,39 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
CHECK (length(display_name) BETWEEN 1 AND 40)
|
CHECK (length(display_name) BETWEEN 1 AND 40)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_wording_keys (
|
||||||
|
key text PRIMARY KEY,
|
||||||
|
module text NOT NULL,
|
||||||
|
surface text NOT NULL CHECK (surface IN ('frontend', 'backend', 'email')),
|
||||||
|
description text NOT NULL DEFAULT '',
|
||||||
|
placeholders jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(placeholders) = 'array'),
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (key ~ '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$'),
|
||||||
|
CHECK (length(module) BETWEEN 1 AND 80)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS system_wording_keys_module_idx
|
||||||
|
ON system_wording_keys(module, key);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS system_wording_keys_surface_idx
|
||||||
|
ON system_wording_keys(surface, key);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_wording_values (
|
||||||
|
key text NOT NULL REFERENCES system_wording_keys(key) ON DELETE CASCADE,
|
||||||
|
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
||||||
|
value text NOT NULL CHECK (length(value) > 0),
|
||||||
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (key, locale)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS system_wording_values_locale_idx
|
||||||
|
ON system_wording_values(locale, key);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -97,6 +130,18 @@ CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
|||||||
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
|
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
|
||||||
ON email_verification_tokens(user_id);
|
ON email_verification_tokens(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash text NOT NULL UNIQUE,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
used_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
|
||||||
|
ON password_reset_tokens(user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -230,6 +275,11 @@ CREATE TABLE IF NOT EXISTS pokemon (
|
|||||||
special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0),
|
special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0),
|
||||||
special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0),
|
special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0),
|
||||||
speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0),
|
speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0),
|
||||||
|
image_path text NOT NULL DEFAULT '',
|
||||||
|
image_style text NOT NULL DEFAULT '',
|
||||||
|
image_version text NOT NULL DEFAULT '',
|
||||||
|
image_variant text NOT NULL DEFAULT '',
|
||||||
|
image_description text NOT NULL DEFAULT '',
|
||||||
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -429,6 +479,11 @@ ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS defense integer NOT NULL DEFAULT 0
|
|||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0);
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0);
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0);
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0);
|
||||||
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0);
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0);
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_style text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_version text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_variant text NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_description text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } fr
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import type { PoolClient, QueryResultRow } from 'pg';
|
import type { PoolClient, QueryResultRow } from 'pg';
|
||||||
import { pool, queryOne } from './db.ts';
|
import { pool, queryOne } from './db.ts';
|
||||||
|
import { systemMessage } from './systemWordingQueries.ts';
|
||||||
|
|
||||||
const scrypt = promisify(scryptCallback);
|
const scrypt = promisify(scryptCallback);
|
||||||
const passwordKeyLength = 64;
|
const passwordKeyLength = 64;
|
||||||
const verificationTokenHours = 24;
|
const verificationTokenHours = 24;
|
||||||
const sessionDays = 30;
|
const passwordResetTokenHours = 1;
|
||||||
|
const rememberedSessionDays = 30;
|
||||||
|
const sessionOnlySessionDays = 1;
|
||||||
const defaultLocale = 'en';
|
const defaultLocale = 'en';
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
@@ -34,11 +37,19 @@ type AuthMessageKey =
|
|||||||
| 'emailAlreadyRegistered'
|
| 'emailAlreadyRegistered'
|
||||||
| 'checkVerificationEmail'
|
| 'checkVerificationEmail'
|
||||||
| 'emailVerified'
|
| 'emailVerified'
|
||||||
|
| 'checkPasswordResetEmail'
|
||||||
|
| 'passwordResetComplete'
|
||||||
| 'invalidCredentials'
|
| 'invalidCredentials'
|
||||||
| 'verifyEmailFirst'
|
| 'verifyEmailFirst'
|
||||||
|
| 'invalidResetToken'
|
||||||
| 'emailSubject'
|
| 'emailSubject'
|
||||||
| 'emailHtml'
|
| 'emailHtml'
|
||||||
| 'emailText';
|
| 'emailText'
|
||||||
|
| 'passwordResetSubject'
|
||||||
|
| 'passwordResetHtml'
|
||||||
|
| 'passwordResetText';
|
||||||
|
|
||||||
|
type AuthTokenMessageKey = 'invalidToken' | 'invalidResetToken';
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -53,87 +64,74 @@ function statusError(message: string, statusCode: number): StatusError {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): string {
|
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): Promise<string> {
|
||||||
const messages: Record<string, Record<AuthMessageKey, string>> = {
|
const messageKeys: Record<AuthMessageKey, string> = {
|
||||||
en: {
|
emailRequired: 'server.auth.emailRequired',
|
||||||
emailRequired: 'Email is required',
|
invalidEmail: 'server.auth.invalidEmail',
|
||||||
invalidEmail: 'Email format is invalid',
|
displayNameRequired: 'server.auth.displayNameRequired',
|
||||||
displayNameRequired: 'Display name is required',
|
displayNameLength: 'server.auth.displayNameLength',
|
||||||
displayNameLength: 'Display name must be 1 to 40 characters',
|
passwordLength: 'server.auth.passwordLength',
|
||||||
passwordLength: 'Password must be at least 8 characters',
|
invalidToken: 'server.auth.invalidToken',
|
||||||
invalidToken: 'The verification link is invalid or expired',
|
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
|
||||||
emailAlreadyRegistered: 'This email is already registered',
|
checkVerificationEmail: 'server.auth.checkVerificationEmail',
|
||||||
checkVerificationEmail: 'Please check your verification email',
|
emailVerified: 'server.auth.emailVerified',
|
||||||
emailVerified: 'Email verified',
|
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
|
||||||
invalidCredentials: 'Email or password is incorrect',
|
passwordResetComplete: 'server.auth.passwordResetComplete',
|
||||||
verifyEmailFirst: 'Please complete email verification first',
|
invalidCredentials: 'server.auth.invalidCredentials',
|
||||||
emailSubject: 'Verify your Pokopia Wiki email',
|
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
||||||
emailHtml:
|
invalidResetToken: 'server.auth.invalidResetToken',
|
||||||
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
|
emailSubject: 'email.auth.verificationSubject',
|
||||||
emailText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.'
|
emailHtml: 'email.auth.verificationHtml',
|
||||||
},
|
emailText: 'email.auth.verificationText',
|
||||||
'zh-CN': {
|
passwordResetSubject: 'email.auth.passwordResetSubject',
|
||||||
emailRequired: '请输入邮箱',
|
passwordResetHtml: 'email.auth.passwordResetHtml',
|
||||||
invalidEmail: '邮箱格式不正确',
|
passwordResetText: 'email.auth.passwordResetText'
|
||||||
displayNameRequired: '请输入显示名',
|
|
||||||
displayNameLength: '显示名长度需为 1 到 40 个字符',
|
|
||||||
passwordLength: '密码至少需要 8 个字符',
|
|
||||||
invalidToken: '验证链接无效或已过期',
|
|
||||||
emailAlreadyRegistered: '该邮箱已注册',
|
|
||||||
checkVerificationEmail: '请查收验证邮件',
|
|
||||||
emailVerified: '邮箱已验证',
|
|
||||||
invalidCredentials: '邮箱或密码不正确',
|
|
||||||
verifyEmailFirst: '请先完成邮箱验证',
|
|
||||||
emailSubject: '验证你的 Pokopia Wiki 邮箱',
|
|
||||||
emailHtml: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
|
|
||||||
emailText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let message = messages[locale]?.[key] ?? messages[defaultLocale][key];
|
return systemMessage(locale || defaultLocale, messageKeys[key], params);
|
||||||
for (const [paramKey, paramValue] of Object.entries(params)) {
|
|
||||||
message = message.replaceAll(`{${paramKey}}`, String(paramValue));
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanEmail(value: unknown, locale: string): string {
|
async function cleanEmail(value: unknown, locale: string): Promise<string> {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw statusError(authMessage(locale, 'emailRequired'), 400);
|
throw statusError(await authMessage(locale, 'emailRequired'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = value.trim().toLowerCase();
|
const email = value.trim().toLowerCase();
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
throw statusError(authMessage(locale, 'invalidEmail'), 400);
|
throw statusError(await authMessage(locale, 'invalidEmail'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return email;
|
return email;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanDisplayName(value: unknown, locale: string): string {
|
async function cleanDisplayName(value: unknown, locale: string): Promise<string> {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw statusError(authMessage(locale, 'displayNameRequired'), 400);
|
throw statusError(await authMessage(locale, 'displayNameRequired'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayName = value.trim();
|
const displayName = value.trim();
|
||||||
if (displayName.length < 1 || displayName.length > 40) {
|
if (displayName.length < 1 || displayName.length > 40) {
|
||||||
throw statusError(authMessage(locale, 'displayNameLength'), 400);
|
throw statusError(await authMessage(locale, 'displayNameLength'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return displayName;
|
return displayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanPassword(value: unknown, locale: string): string {
|
async function cleanPassword(value: unknown, locale: string): Promise<string> {
|
||||||
if (typeof value !== 'string' || value.length < 8) {
|
if (typeof value !== 'string' || value.length < 8) {
|
||||||
throw statusError(authMessage(locale, 'passwordLength'), 400);
|
throw statusError(await authMessage(locale, 'passwordLength'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanToken(value: unknown, locale: string): string {
|
async function cleanToken(
|
||||||
|
value: unknown,
|
||||||
|
locale: string,
|
||||||
|
messageKey: AuthTokenMessageKey = 'invalidToken'
|
||||||
|
): Promise<string> {
|
||||||
if (typeof value !== 'string' || value.trim().length < 32) {
|
if (typeof value !== 'string' || value.trim().length < 32) {
|
||||||
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
throw statusError(await authMessage(locale, messageKey), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.trim();
|
return value.trim();
|
||||||
@@ -209,16 +207,27 @@ function getEmailConfig() {
|
|||||||
return { apiKey, from };
|
return { apiKey, from };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildVerificationUrl(token: string): string {
|
function buildTokenUrl(pathname: string, token: string): string {
|
||||||
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
|
||||||
const url = new URL('/verify-email', origin);
|
const url = new URL(pathname, origin);
|
||||||
url.searchParams.set('token', token);
|
url.searchParams.set('token', token);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildVerificationUrl(token: string): string {
|
||||||
|
return buildTokenUrl('/verify-email', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPasswordResetUrl(token: string): string {
|
||||||
|
return buildTokenUrl('/reset-password', token);
|
||||||
|
}
|
||||||
|
|
||||||
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
|
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
|
||||||
const { apiKey, from } = getEmailConfig();
|
const { apiKey, from } = getEmailConfig();
|
||||||
const verificationUrl = buildVerificationUrl(token);
|
const verificationUrl = buildVerificationUrl(token);
|
||||||
|
const subject = await authMessage(locale, 'emailSubject');
|
||||||
|
const html = await authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours });
|
||||||
|
const text = await authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours });
|
||||||
const response = await fetch('https://api.resend.com/emails', {
|
const response = await fetch('https://api.resend.com/emails', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -228,9 +237,36 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from,
|
from,
|
||||||
to: [email],
|
to: [email],
|
||||||
subject: authMessage(locale, 'emailSubject'),
|
subject,
|
||||||
html: authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }),
|
html,
|
||||||
text: authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours })
|
text
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise<void> {
|
||||||
|
const { apiKey, from } = getEmailConfig();
|
||||||
|
const resetUrl = buildPasswordResetUrl(token);
|
||||||
|
const subject = await authMessage(locale, 'passwordResetSubject');
|
||||||
|
const html = await authMessage(locale, 'passwordResetHtml', { url: resetUrl, hours: passwordResetTokenHours });
|
||||||
|
const text = await authMessage(locale, 'passwordResetText', { url: resetUrl, hours: passwordResetTokenHours });
|
||||||
|
const response = await fetch('https://api.resend.com/emails', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from,
|
||||||
|
to: [email],
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
text
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,9 +277,9 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
const displayName = cleanDisplayName(payload.displayName, locale);
|
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||||
const password = cleanPassword(payload.password, locale);
|
const password = await cleanPassword(payload.password, locale);
|
||||||
const passwordHash = await hashPassword(password);
|
const passwordHash = await hashPassword(password);
|
||||||
const verificationToken = createPlainToken();
|
const verificationToken = createPlainToken();
|
||||||
const verificationTokenHash = hashToken(verificationToken);
|
const verificationTokenHash = hashToken(verificationToken);
|
||||||
@@ -256,7 +292,7 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (existingUser?.email_verified_at) {
|
if (existingUser?.email_verified_at) {
|
||||||
throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409);
|
throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = existingUser
|
const user = existingUser
|
||||||
@@ -295,11 +331,11 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
|||||||
});
|
});
|
||||||
|
|
||||||
await sendVerificationEmail(email, verificationToken, locale);
|
await sendVerificationEmail(email, verificationToken, locale);
|
||||||
return { message: authMessage(locale, 'checkVerificationEmail') };
|
return { message: await authMessage(locale, 'checkVerificationEmail') };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
|
export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const token = cleanToken(payload.token, locale);
|
const token = await cleanToken(payload.token, locale);
|
||||||
const tokenHash = hashToken(token);
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
return withTransaction(async (client) => {
|
return withTransaction(async (client) => {
|
||||||
@@ -317,7 +353,7 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!tokenRow) {
|
if (!tokenRow) {
|
||||||
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await clientQueryOne<UserRow>(
|
const user = await clientQueryOne<UserRow>(
|
||||||
@@ -332,31 +368,112 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
||||||
user.id
|
user.id
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { message: authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
|
return { message: await authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
|
const email = await cleanEmail(payload.email, locale);
|
||||||
|
const user = await queryOne<UserRow>(
|
||||||
|
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
const resetToken = createPlainToken();
|
||||||
|
const resetTokenHash = hashToken(resetToken);
|
||||||
|
|
||||||
|
await withTransaction(async (client) => {
|
||||||
|
await client.query('DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]);
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
|
||||||
|
VALUES ($1, $2, now() + ($3 * interval '1 hour'))
|
||||||
|
`,
|
||||||
|
[user.id, resetTokenHash, passwordResetTokenHours]
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendPasswordResetEmail(email, resetToken, locale);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Password reset email failed', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { message: await authMessage(locale, 'checkPasswordResetEmail') };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
|
const token = await cleanToken(payload.token, locale, 'invalidResetToken');
|
||||||
|
const password = await cleanPassword(payload.password, locale);
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
const tokenHash = hashToken(token);
|
||||||
|
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const tokenRow = await clientQueryOne<{ id: number; user_id: number }>(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
SELECT id, user_id
|
||||||
|
FROM password_reset_tokens
|
||||||
|
WHERE token_hash = $1
|
||||||
|
AND used_at IS NULL
|
||||||
|
AND expires_at > now()
|
||||||
|
FOR UPDATE
|
||||||
|
`,
|
||||||
|
[tokenHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!tokenRow) {
|
||||||
|
throw statusError(await authMessage(locale, 'invalidResetToken'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await clientQueryOne<UserRow>(
|
||||||
|
client,
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET password_hash = $1, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, email, display_name, email_verified_at
|
||||||
|
`,
|
||||||
|
[passwordHash, tokenRow.user_id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw statusError(await authMessage(locale, 'invalidResetToken'), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
||||||
|
user.id
|
||||||
|
]);
|
||||||
|
await client.query('DELETE FROM user_sessions WHERE user_id = $1', [user.id]);
|
||||||
|
|
||||||
|
return { message: await authMessage(locale, 'passwordResetComplete') };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
const password = cleanPassword(payload.password, locale);
|
const password = await cleanPassword(payload.password, locale);
|
||||||
|
const sessionDays = payload.rememberMe === true ? rememberedSessionDays : sessionOnlySessionDays;
|
||||||
const user = await queryOne<LoginUserRow>(
|
const user = await queryOne<LoginUserRow>(
|
||||||
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
||||||
[email]
|
[email]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||||
throw statusError(authMessage(locale, 'invalidCredentials'), 401);
|
throw statusError(await authMessage(locale, 'invalidCredentials'), 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.email_verified_at) {
|
if (!user.email_verified_at) {
|
||||||
throw statusError(authMessage(locale, 'verifyEmailFirst'), 403);
|
throw statusError(await authMessage(locale, 'verifyEmailFirst'), 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionToken = createPlainToken();
|
const sessionToken = createPlainToken();
|
||||||
@@ -390,6 +507,29 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
|||||||
return user ? toPublicUser(user) : null;
|
return user ? toPublicUser(user) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateCurrentUser(
|
||||||
|
userId: number,
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
locale = defaultLocale
|
||||||
|
): Promise<AuthUser> {
|
||||||
|
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||||
|
const user = await queryOne<UserRow>(
|
||||||
|
`
|
||||||
|
UPDATE users
|
||||||
|
SET display_name = $1, updated_at = now()
|
||||||
|
WHERE id = $2
|
||||||
|
RETURNING id, email, display_name, email_verified_at
|
||||||
|
`,
|
||||||
|
[displayName, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw statusError(await systemMessage(locale || defaultLocale, 'server.errors.loginRequired'), 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return toPublicUser(user);
|
||||||
|
}
|
||||||
|
|
||||||
export async function logoutSession(token: string): Promise<void> {
|
export async function logoutSession(token: string): Promise<void> {
|
||||||
if (token.length < 32) {
|
if (token.length < 32) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -70,6 +70,23 @@ type PokemonStats = {
|
|||||||
speed: number;
|
speed: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PokemonImage = {
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
style: string;
|
||||||
|
version: string;
|
||||||
|
variant: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PokemonImageCandidate = Omit<PokemonImage, 'url'>;
|
||||||
|
|
||||||
|
type PokemonImageOptionsResult = {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
images: PokemonImage[];
|
||||||
|
};
|
||||||
|
|
||||||
type PokemonPayload = {
|
type PokemonPayload = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -84,6 +101,7 @@ type PokemonPayload = {
|
|||||||
skillIds: number[];
|
skillIds: number[];
|
||||||
favoriteThingIds: number[];
|
favoriteThingIds: number[];
|
||||||
skillItemDrops: SkillItemDrop[];
|
skillItemDrops: SkillItemDrop[];
|
||||||
|
image: PokemonImage | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PokemonFetchResult = {
|
type PokemonFetchResult = {
|
||||||
@@ -255,6 +273,7 @@ type PokemonChangeSource = {
|
|||||||
details: string;
|
details: string;
|
||||||
heightInches: number;
|
heightInches: number;
|
||||||
weightPounds: number;
|
weightPounds: number;
|
||||||
|
image: PokemonImage | null;
|
||||||
types: Array<{ name: string }>;
|
types: Array<{ name: string }>;
|
||||||
stats: PokemonStats;
|
stats: PokemonStats;
|
||||||
environment: { name: string };
|
environment: { name: string };
|
||||||
@@ -289,6 +308,8 @@ const defaultLifePostLimit = 20;
|
|||||||
const maxLifePostLimit = 50;
|
const maxLifePostLimit = 50;
|
||||||
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
||||||
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1));
|
||||||
|
const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com';
|
||||||
|
const pokemonSpriteRequestTimeoutMs = 2500;
|
||||||
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
||||||
{ key: 'hp', label: 'HP' },
|
{ key: 'hp', label: 'HP' },
|
||||||
{ key: 'attack', label: 'Attack' },
|
{ key: 'attack', label: 'Attack' },
|
||||||
@@ -1013,6 +1034,231 @@ function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: str
|
|||||||
return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback;
|
return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pokemonSpriteUrl(path: string): string {
|
||||||
|
return `${pokemonSpriteBaseUrl}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage {
|
||||||
|
return { ...candidate, url: pokemonSpriteUrl(candidate.path) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonImageCandidates(id: number): PokemonImageCandidate[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/official-artwork/${id}.png`,
|
||||||
|
style: 'Official artwork',
|
||||||
|
version: 'Official artwork',
|
||||||
|
variant: 'Default',
|
||||||
|
description: 'Large official artwork'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/official-artwork/shiny/${id}.png`,
|
||||||
|
style: 'Official artwork',
|
||||||
|
version: 'Official artwork',
|
||||||
|
variant: 'Shiny',
|
||||||
|
description: 'Large shiny official artwork'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/home/${id}.png`,
|
||||||
|
style: 'Pokemon HOME',
|
||||||
|
version: 'HOME',
|
||||||
|
variant: 'Default',
|
||||||
|
description: 'Modern HOME render'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/home/shiny/${id}.png`,
|
||||||
|
style: 'Pokemon HOME',
|
||||||
|
version: 'HOME',
|
||||||
|
variant: 'Shiny',
|
||||||
|
description: 'Modern shiny HOME render'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/home/female/${id}.png`,
|
||||||
|
style: 'Pokemon HOME',
|
||||||
|
version: 'HOME',
|
||||||
|
variant: 'Female',
|
||||||
|
description: 'Modern female HOME render'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/home/shiny/female/${id}.png`,
|
||||||
|
style: 'Pokemon HOME',
|
||||||
|
version: 'HOME',
|
||||||
|
variant: 'Shiny female',
|
||||||
|
description: 'Modern shiny female HOME render'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/dream-world/${id}.svg`,
|
||||||
|
style: 'Dream World',
|
||||||
|
version: 'Dream World',
|
||||||
|
variant: 'Default',
|
||||||
|
description: 'Dream World SVG artwork'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/dream-world/female/${id}.svg`,
|
||||||
|
style: 'Dream World',
|
||||||
|
version: 'Dream World',
|
||||||
|
variant: 'Female',
|
||||||
|
description: 'Dream World female SVG artwork'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/showdown/${id}.gif`,
|
||||||
|
style: 'Pokemon Showdown',
|
||||||
|
version: 'Showdown',
|
||||||
|
variant: 'Front animated',
|
||||||
|
description: 'Animated front battle sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/showdown/shiny/${id}.gif`,
|
||||||
|
style: 'Pokemon Showdown',
|
||||||
|
version: 'Showdown',
|
||||||
|
variant: 'Shiny front animated',
|
||||||
|
description: 'Animated shiny front battle sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/showdown/female/${id}.gif`,
|
||||||
|
style: 'Pokemon Showdown',
|
||||||
|
version: 'Showdown',
|
||||||
|
variant: 'Female front animated',
|
||||||
|
description: 'Animated female front battle sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/other/showdown/back/${id}.gif`,
|
||||||
|
style: 'Pokemon Showdown',
|
||||||
|
version: 'Showdown',
|
||||||
|
variant: 'Back animated',
|
||||||
|
description: 'Animated back battle sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/${id}.png`,
|
||||||
|
style: 'Default sprite',
|
||||||
|
version: 'PokeAPI',
|
||||||
|
variant: 'Front',
|
||||||
|
description: 'Compact front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/shiny/${id}.png`,
|
||||||
|
style: 'Default sprite',
|
||||||
|
version: 'PokeAPI',
|
||||||
|
variant: 'Shiny front',
|
||||||
|
description: 'Compact shiny front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/female/${id}.png`,
|
||||||
|
style: 'Default sprite',
|
||||||
|
version: 'PokeAPI',
|
||||||
|
variant: 'Female front',
|
||||||
|
description: 'Compact female front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/back/${id}.png`,
|
||||||
|
style: 'Default sprite',
|
||||||
|
version: 'PokeAPI',
|
||||||
|
variant: 'Back',
|
||||||
|
description: 'Compact back sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/back/shiny/${id}.png`,
|
||||||
|
style: 'Default sprite',
|
||||||
|
version: 'PokeAPI',
|
||||||
|
variant: 'Shiny back',
|
||||||
|
description: 'Compact shiny back sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-v/black-white/animated/${id}.gif`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'Black / White',
|
||||||
|
variant: 'Animated front',
|
||||||
|
description: 'Generation V animated sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-v/black-white/animated/shiny/${id}.gif`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'Black / White',
|
||||||
|
variant: 'Animated shiny',
|
||||||
|
description: 'Generation V animated shiny sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-v/black-white/${id}.png`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'Black / White',
|
||||||
|
variant: 'Front',
|
||||||
|
description: 'Generation V front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-vi/x-y/${id}.png`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'X / Y',
|
||||||
|
variant: 'Front',
|
||||||
|
description: 'Generation VI front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/${id}.png`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'Ultra Sun / Ultra Moon',
|
||||||
|
variant: 'Front',
|
||||||
|
description: 'Generation VII front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-ix/scarlet-violet/${id}.png`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'Scarlet / Violet',
|
||||||
|
variant: 'Front',
|
||||||
|
description: 'Generation IX front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-iii/emerald/${id}.png`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'Emerald',
|
||||||
|
variant: 'Front',
|
||||||
|
description: 'Generation III front sprite'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: `/sprites/pokemon/versions/generation-i/red-blue/${id}.png`,
|
||||||
|
style: 'Game version',
|
||||||
|
version: 'Red / Blue',
|
||||||
|
variant: 'Front',
|
||||||
|
description: 'Generation I front sprite'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonImageLabel(image: PokemonImage | null | undefined): string {
|
||||||
|
return image ? `${image.style} - ${image.version} - ${image.variant}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null {
|
||||||
|
const cleanPath = path.trim();
|
||||||
|
const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath);
|
||||||
|
return candidate ? pokemonImageWithUrl(candidate) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | null {
|
||||||
|
const path = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (path === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = pokemonImageCandidateForPath(pokemonId, path);
|
||||||
|
if (!image) {
|
||||||
|
throw validationError('Pokemon image path is invalid');
|
||||||
|
}
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pokemonImageExists(candidate: PokemonImageCandidate): Promise<boolean> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), pokemonSpriteRequestTimeoutMs);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(pokemonSpriteUrl(candidate.path), { method: 'HEAD', signal: controller.signal });
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void {
|
function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return;
|
return;
|
||||||
@@ -1158,6 +1404,29 @@ export async function fetchPokemonData(payload: Record<string, unknown>, userId:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPokemonImageOptions(payload: Record<string, unknown>): Promise<PokemonImageOptionsResult> {
|
||||||
|
const lookupKey = pokemonDataLookupKey(payload.identifier);
|
||||||
|
const data = await loadPokemonCsvData();
|
||||||
|
const pokemonRow = data.pokemonByLookup.get(lookupKey);
|
||||||
|
|
||||||
|
if (!pokemonRow) {
|
||||||
|
throw validationError('Pokemon data was not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = csvInteger(pokemonRow, 'id');
|
||||||
|
const images = (
|
||||||
|
await Promise.all(
|
||||||
|
pokemonImageCandidates(id).map(async (candidate) => (await pokemonImageExists(candidate) ? pokemonImageWithUrl(candidate) : null))
|
||||||
|
)
|
||||||
|
).filter((image): image is PokemonImage => image !== null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
identifier: csvText(pokemonRow, 'identifier'),
|
||||||
|
images
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption {
|
function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption {
|
||||||
const id = csvInteger(row, 'id');
|
const id = csvInteger(row, 'id');
|
||||||
const identifier = csvText(row, 'identifier');
|
const identifier = csvText(row, 'identifier');
|
||||||
@@ -1361,6 +1630,7 @@ async function pokemonEditChanges(
|
|||||||
pushChange(changes, 'Details', before.details, after.details);
|
pushChange(changes, 'Details', before.details, after.details);
|
||||||
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
|
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
|
||||||
pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
|
pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
|
||||||
|
pushChange(changes, 'Image', pokemonImageLabel(before.image), pokemonImageLabel(after.image));
|
||||||
pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
|
pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
|
||||||
pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
|
pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
|
||||||
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
|
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
|
||||||
@@ -1479,6 +1749,14 @@ function pokemonProjection(locale: string): string {
|
|||||||
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
|
||||||
p.weight_pounds AS "weightPounds",
|
p.weight_pounds AS "weightPounds",
|
||||||
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
|
||||||
|
CASE WHEN p.image_path <> '' THEN json_build_object(
|
||||||
|
'path', p.image_path,
|
||||||
|
'url', '${pokemonSpriteBaseUrl}' || p.image_path,
|
||||||
|
'style', p.image_style,
|
||||||
|
'version', p.image_version,
|
||||||
|
'variant', p.image_variant,
|
||||||
|
'description', p.image_description
|
||||||
|
) ELSE NULL END AS image,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'hp', p.hp,
|
'hp', p.hp,
|
||||||
'attack', p.attack,
|
'attack', p.attack,
|
||||||
@@ -2847,8 +3125,10 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = requirePositiveInteger(payload.id, 'Pokemon ID is required');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: requirePositiveInteger(payload.id, 'Pokemon ID is required'),
|
id,
|
||||||
name: cleanName(payload.name, 'Pokemon name is required'),
|
name: cleanName(payload.name, 'Pokemon name is required'),
|
||||||
genus: cleanOptionalText(payload.genus),
|
genus: cleanOptionalText(payload.genus),
|
||||||
details: cleanOptionalText(payload.details),
|
details: cleanOptionalText(payload.details),
|
||||||
@@ -2860,7 +3140,8 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
|||||||
environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'),
|
environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'),
|
||||||
skillIds,
|
skillIds,
|
||||||
favoriteThingIds,
|
favoriteThingIds,
|
||||||
skillItemDrops: [...skillItemDrops.values()]
|
skillItemDrops: [...skillItemDrops.values()],
|
||||||
|
image: cleanPokemonImage(payload.imagePath, id)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2930,11 +3211,16 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
|||||||
special_attack,
|
special_attack,
|
||||||
special_defense,
|
special_defense,
|
||||||
speed,
|
speed,
|
||||||
|
image_path,
|
||||||
|
image_style,
|
||||||
|
image_version,
|
||||||
|
image_variant,
|
||||||
|
image_description,
|
||||||
sort_order,
|
sort_order,
|
||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
updated_by_user_id
|
updated_by_user_id
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $20)
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
cleanPayload.id,
|
cleanPayload.id,
|
||||||
@@ -2950,6 +3236,11 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
|||||||
cleanPayload.stats.specialAttack,
|
cleanPayload.stats.specialAttack,
|
||||||
cleanPayload.stats.specialDefense,
|
cleanPayload.stats.specialDefense,
|
||||||
cleanPayload.stats.speed,
|
cleanPayload.stats.speed,
|
||||||
|
cleanPayload.image?.path ?? '',
|
||||||
|
cleanPayload.image?.style ?? '',
|
||||||
|
cleanPayload.image?.version ?? '',
|
||||||
|
cleanPayload.image?.variant ?? '',
|
||||||
|
cleanPayload.image?.description ?? '',
|
||||||
sortOrder,
|
sortOrder,
|
||||||
userId
|
userId
|
||||||
]
|
]
|
||||||
@@ -2983,9 +3274,14 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
|||||||
special_attack = $10,
|
special_attack = $10,
|
||||||
special_defense = $11,
|
special_defense = $11,
|
||||||
speed = $12,
|
speed = $12,
|
||||||
updated_by_user_id = $13,
|
image_path = $13,
|
||||||
|
image_style = $14,
|
||||||
|
image_version = $15,
|
||||||
|
image_variant = $16,
|
||||||
|
image_description = $17,
|
||||||
|
updated_by_user_id = $18,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $14
|
WHERE id = $19
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
@@ -3000,6 +3296,11 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
|||||||
cleanPayload.stats.specialAttack,
|
cleanPayload.stats.specialAttack,
|
||||||
cleanPayload.stats.specialDefense,
|
cleanPayload.stats.specialDefense,
|
||||||
cleanPayload.stats.speed,
|
cleanPayload.stats.speed,
|
||||||
|
cleanPayload.image?.path ?? '',
|
||||||
|
cleanPayload.image?.style ?? '',
|
||||||
|
cleanPayload.image?.version ?? '',
|
||||||
|
cleanPayload.image?.variant ?? '',
|
||||||
|
cleanPayload.image?.description ?? '',
|
||||||
userId,
|
userId,
|
||||||
id
|
id
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
|
import {
|
||||||
|
getUserBySessionToken,
|
||||||
|
loginUser,
|
||||||
|
logoutSession,
|
||||||
|
registerUser,
|
||||||
|
requestPasswordReset,
|
||||||
|
resetPassword,
|
||||||
|
updateCurrentUser,
|
||||||
|
verifyEmail,
|
||||||
|
type AuthUser
|
||||||
|
} from './auth.ts';
|
||||||
import { initializeDatabase, pool } from './db.ts';
|
import { initializeDatabase, pool } from './db.ts';
|
||||||
import {
|
import {
|
||||||
cleanLocale,
|
cleanLocale,
|
||||||
@@ -29,6 +39,7 @@ import {
|
|||||||
deletePokemon,
|
deletePokemon,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
fetchPokemonData,
|
fetchPokemonData,
|
||||||
|
fetchPokemonImageOptions,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
getItem,
|
getItem,
|
||||||
getOptions,
|
getOptions,
|
||||||
@@ -62,6 +73,14 @@ import {
|
|||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe
|
updateRecipe
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
|
import {
|
||||||
|
getSystemWordings,
|
||||||
|
listSystemWordingRows,
|
||||||
|
localizedStatusMessage,
|
||||||
|
syncSystemWordingCatalog,
|
||||||
|
systemMessage,
|
||||||
|
updateSystemWordingValue
|
||||||
|
} from './systemWordingQueries.ts';
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: true
|
logger: true
|
||||||
@@ -78,23 +97,23 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
const locale = requestLocale(_request);
|
const locale = requestLocale(_request);
|
||||||
|
|
||||||
if (pgError.code === '23503') {
|
if (pgError.code === '23503') {
|
||||||
return reply.code(409).send({ message: serverMessage(locale, 'foreignKey') });
|
return reply.code(409).send({ message: await serverMessage(locale, 'foreignKey') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.code === '23505') {
|
if (pgError.code === '23505') {
|
||||||
return reply.code(409).send({ message: serverMessage(locale, 'duplicate') });
|
return reply.code(409).send({ message: await serverMessage(locale, 'duplicate') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.code === '23514') {
|
if (pgError.code === '23514') {
|
||||||
return reply.code(400).send({ message: serverMessage(locale, 'invalidField') });
|
return reply.code(400).send({ message: await serverMessage(locale, 'invalidField') });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pgError.statusCode && pgError.statusCode < 500) {
|
if (pgError.statusCode && pgError.statusCode < 500) {
|
||||||
return reply.code(pgError.statusCode).send({ message: pgError.message });
|
return reply.code(pgError.statusCode).send({ message: await localizedStatusMessage(locale, pgError.message) });
|
||||||
}
|
}
|
||||||
|
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
return reply.code(500).send({ message: serverMessage(locale, 'serverError') });
|
return reply.code(500).send({ message: await serverMessage(locale, 'serverError') });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/health', async () => ({ ok: true }));
|
app.get('/health', async () => ({ ok: true }));
|
||||||
@@ -111,27 +130,15 @@ function requestLocale(request: FastifyRequest): string {
|
|||||||
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
||||||
}
|
}
|
||||||
|
|
||||||
function serverMessage(locale: string, key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst'): string {
|
function serverMessage(
|
||||||
const messages = {
|
locale: string,
|
||||||
en: {
|
key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst' | 'notFound'
|
||||||
foreignKey: 'Referenced data does not exist or the record is currently in use',
|
): Promise<string> {
|
||||||
duplicate: 'A record with the same name or ID already exists',
|
return systemMessage(locale, `server.errors.${key}`);
|
||||||
invalidField: 'Field value is invalid',
|
}
|
||||||
serverError: 'Server error',
|
|
||||||
loginRequired: 'Please log in first',
|
|
||||||
verifyEmailFirst: 'Please complete email verification first'
|
|
||||||
},
|
|
||||||
'zh-CN': {
|
|
||||||
foreignKey: '引用的数据不存在,或当前记录正在被使用',
|
|
||||||
duplicate: '同名或相同 ID 的记录已存在',
|
|
||||||
invalidField: '字段值不合法',
|
|
||||||
serverError: '服务器错误',
|
|
||||||
loginRequired: '请先登录',
|
|
||||||
verifyEmailFirst: '请先完成邮箱验证'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return messages[locale as keyof typeof messages]?.[key] ?? messages.en[key];
|
async function notFound(reply: FastifyReply, request: FastifyRequest) {
|
||||||
|
return reply.code(404).send({ message: await serverMessage(requestLocale(request), 'notFound') });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
||||||
@@ -140,12 +147,12 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
|||||||
const locale = requestLocale(request);
|
const locale = requestLocale(request);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
reply.code(401).send({ message: serverMessage(locale, 'loginRequired') });
|
reply.code(401).send({ message: await serverMessage(locale, 'loginRequired') });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.emailVerified) {
|
if (!user.emailVerified) {
|
||||||
reply.code(403).send({ message: serverMessage(locale, 'verifyEmailFirst') });
|
reply.code(403).send({ message: await serverMessage(locale, 'verifyEmailFirst') });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,17 +180,37 @@ app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body a
|
|||||||
|
|
||||||
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, requestLocale(request)));
|
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, requestLocale(request)));
|
||||||
|
|
||||||
|
app.post('/api/auth/request-password-reset', async (request) =>
|
||||||
|
requestPasswordReset(request.body as Record<string, unknown>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/api/auth/reset-password', async (request) =>
|
||||||
|
resetPassword(request.body as Record<string, unknown>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
app.get('/api/auth/me', async (request, reply) => {
|
app.get('/api/auth/me', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return reply.code(401).send({ message: serverMessage(requestLocale(request), 'loginRequired') });
|
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { user };
|
return { user };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.patch('/api/auth/me', async (request, reply) => {
|
||||||
|
const token = getBearerToken(request.headers.authorization);
|
||||||
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
|
||||||
|
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/auth/logout', async (request, reply) => {
|
app.post('/api/auth/logout', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -195,6 +222,8 @@ app.post('/api/auth/logout', async (request, reply) => {
|
|||||||
|
|
||||||
app.get('/api/languages', async () => listLanguages());
|
app.get('/api/languages', async () => listLanguages());
|
||||||
|
|
||||||
|
app.get('/api/system-wordings', async (request) => getSystemWordings(requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
app.get('/api/options', async (request) => getOptions(requestLocale(request)));
|
||||||
|
|
||||||
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
|
||||||
@@ -218,7 +247,7 @@ app.post('/api/life-posts/:postId/comments', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { postId } = request.params as { postId: string };
|
const { postId } = request.params as { postId: string };
|
||||||
const comment = await createLifeComment(Number(postId), request.body as Record<string, unknown>, user.id);
|
const comment = await createLifeComment(Number(postId), request.body as Record<string, unknown>, user.id);
|
||||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => {
|
app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => {
|
||||||
@@ -233,7 +262,7 @@ app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request,
|
|||||||
request.body as Record<string, unknown>,
|
request.body as Record<string, unknown>,
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/life-posts/:id', async (request, reply) => {
|
app.put('/api/life-posts/:id', async (request, reply) => {
|
||||||
@@ -243,7 +272,7 @@ app.put('/api/life-posts/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
const post = await updateLifePost(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
return post ? post : reply.code(404).send({ message: 'Not found' });
|
return post ? post : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||||
@@ -253,7 +282,7 @@ app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
const post = await setLifePostReaction(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
return post ? post : reply.code(404).send({ message: 'Not found' });
|
return post ? post : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||||
@@ -263,7 +292,7 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request));
|
const post = await deleteLifePostReaction(Number(id), user.id, requestLocale(request));
|
||||||
return post ? post : reply.code(404).send({ message: 'Not found' });
|
return post ? post : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/life-posts/:id', async (request, reply) => {
|
app.delete('/api/life-posts/:id', async (request, reply) => {
|
||||||
@@ -273,7 +302,7 @@ app.delete('/api/life-posts/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteLifePost(Number(id), user.id);
|
const deleted = await deleteLifePost(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/life-comments/:id', async (request, reply) => {
|
app.delete('/api/life-comments/:id', async (request, reply) => {
|
||||||
@@ -283,13 +312,13 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteLifeComment(Number(id), user.id);
|
const deleted = await deleteLifeComment(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
app.get('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||||
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||||
const comments = await listEntityDiscussionComments(entityType, Number(entityId));
|
const comments = await listEntityDiscussionComments(entityType, Number(entityId));
|
||||||
return comments ? comments : reply.code(404).send({ message: 'Not found' });
|
return comments ? comments : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
app.post('/api/discussions/:entityType/:entityId/comments', async (request, reply) => {
|
||||||
@@ -305,7 +334,7 @@ app.post('/api/discussions/:entityType/:entityId/comments', async (request, repl
|
|||||||
request.body as Record<string, unknown>,
|
request.body as Record<string, unknown>,
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => {
|
app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => {
|
||||||
@@ -326,7 +355,7 @@ app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', a
|
|||||||
request.body as Record<string, unknown>,
|
request.body as Record<string, unknown>,
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
||||||
@@ -337,7 +366,7 @@ app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
|||||||
|
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteEntityDiscussionComment(Number(id), user.id);
|
const deleted = await deleteEntityDiscussionComment(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/pokemon', async (request) =>
|
app.get('/api/pokemon', async (request) =>
|
||||||
@@ -356,7 +385,7 @@ app.get('/api/pokemon/:id', async (request, reply) => {
|
|||||||
const pokemon = await getPokemon(Number(id), requestLocale(request));
|
const pokemon = await getPokemon(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!pokemon) {
|
if (!pokemon) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pokemon;
|
return pokemon;
|
||||||
@@ -374,6 +403,11 @@ app.post('/api/pokemon/fetch', async (request, reply) => {
|
|||||||
return user ? fetchPokemonData(request.body as Record<string, unknown>, user.id) : undefined;
|
return user ? fetchPokemonData(request.body as Record<string, unknown>, user.id) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/pokemon/image-options', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? fetchPokemonImageOptions(request.body as Record<string, unknown>) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/pokemon/:id', async (request, reply) => {
|
app.put('/api/pokemon/:id', async (request, reply) => {
|
||||||
const user = await requireVerifiedUser(request, reply);
|
const user = await requireVerifiedUser(request, reply);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -383,7 +417,7 @@ app.put('/api/pokemon/:id', async (request, reply) => {
|
|||||||
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!pokemon) {
|
if (!pokemon) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pokemon;
|
return pokemon;
|
||||||
@@ -396,7 +430,7 @@ app.delete('/api/pokemon/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deletePokemon(Number(id), user.id);
|
const deleted = await deletePokemon(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/habitats', async (request) => listHabitats(requestLocale(request)));
|
app.get('/api/habitats', async (request) => listHabitats(requestLocale(request)));
|
||||||
@@ -406,7 +440,7 @@ app.get('/api/habitats/:id', async (request, reply) => {
|
|||||||
const habitat = await getHabitat(Number(id), requestLocale(request));
|
const habitat = await getHabitat(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!habitat) {
|
if (!habitat) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return habitat;
|
return habitat;
|
||||||
@@ -428,7 +462,7 @@ app.put('/api/habitats/:id', async (request, reply) => {
|
|||||||
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!habitat) {
|
if (!habitat) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return habitat;
|
return habitat;
|
||||||
@@ -441,7 +475,7 @@ app.delete('/api/habitats/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteHabitat(Number(id), user.id);
|
const deleted = await deleteHabitat(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/items', async (request) =>
|
app.get('/api/items', async (request) =>
|
||||||
@@ -453,7 +487,7 @@ app.get('/api/items/:id', async (request, reply) => {
|
|||||||
const item = await getItem(Number(id), requestLocale(request));
|
const item = await getItem(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -475,7 +509,7 @@ app.put('/api/items/:id', async (request, reply) => {
|
|||||||
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -488,7 +522,7 @@ app.delete('/api/items/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteItem(Number(id), user.id);
|
const deleted = await deleteItem(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/recipes', async (request) =>
|
app.get('/api/recipes', async (request) =>
|
||||||
@@ -500,7 +534,7 @@ app.get('/api/recipes/:id', async (request, reply) => {
|
|||||||
const recipe = await getRecipe(Number(id), requestLocale(request));
|
const recipe = await getRecipe(Number(id), requestLocale(request));
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return recipe;
|
return recipe;
|
||||||
@@ -522,7 +556,7 @@ app.put('/api/recipes/:id', async (request, reply) => {
|
|||||||
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
if (!recipe) {
|
if (!recipe) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
return recipe;
|
return recipe;
|
||||||
@@ -535,7 +569,7 @@ app.delete('/api/recipes/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteRecipe(Number(id), user.id);
|
const deleted = await deleteRecipe(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/daily-checklist', async (request, reply) => {
|
app.post('/api/admin/daily-checklist', async (request, reply) => {
|
||||||
@@ -564,7 +598,7 @@ app.put('/api/admin/daily-checklist/:id', async (request, reply) => {
|
|||||||
user.id,
|
user.id,
|
||||||
requestLocale(request)
|
requestLocale(request)
|
||||||
);
|
);
|
||||||
return item ? item : reply.code(404).send({ message: 'Not found' });
|
return item ? item : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||||
@@ -574,7 +608,7 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const deleted = await deleteDailyChecklistItem(Number(id), user.id);
|
const deleted = await deleteDailyChecklistItem(Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/admin/pokemon/order', async (request, reply) => {
|
app.put('/api/admin/pokemon/order', async (request, reply) => {
|
||||||
@@ -628,7 +662,21 @@ app.delete('/api/admin/languages/:code', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { code } = request.params as { code: string };
|
const { code } = request.params as { code: string };
|
||||||
const deleted = await deleteLanguage(code);
|
const deleted = await deleteLanguage(code);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/admin/system-wordings', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
return user ? listSystemWordingRows(request.query as Record<string, unknown>) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/system-wordings/:key', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { key } = request.params as { key: string };
|
||||||
|
return updateSystemWordingValue(key, request.body as Record<string, unknown>, user.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||||
@@ -638,7 +686,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { type } = request.params as { type: string };
|
const { type } = request.params as { type: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
return listConfig(type, requestLocale(request));
|
return listConfig(type, requestLocale(request));
|
||||||
});
|
});
|
||||||
@@ -650,7 +698,7 @@ app.post('/api/admin/config/:type', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { type } = request.params as { type: string };
|
const { type } = request.params as { type: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
.code(201)
|
.code(201)
|
||||||
@@ -664,7 +712,7 @@ app.put('/api/admin/config/:type/order', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { type } = request.params as { type: string };
|
const { type } = request.params as { type: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
return reorderConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request));
|
return reorderConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
});
|
});
|
||||||
@@ -676,10 +724,10 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { type, id } = request.params as { type: string; id: string };
|
const { type, id } = request.params as { type: string; id: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
return config ? config : reply.code(404).send({ message: 'Not found' });
|
return config ? config : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
|
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
|
||||||
@@ -689,16 +737,17 @@ app.delete('/api/admin/config/:type/:id', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
const { type, id } = request.params as { type: string; id: string };
|
const { type, id } = request.params as { type: string; id: string };
|
||||||
if (!isConfigType(type)) {
|
if (!isConfigType(type)) {
|
||||||
return reply.code(404).send({ message: 'Not found' });
|
return notFound(reply, request);
|
||||||
}
|
}
|
||||||
const deleted = await deleteConfig(type, Number(id), user.id);
|
const deleted = await deleteConfig(type, Number(id), user.id);
|
||||||
return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = Number(process.env.BACKEND_PORT ?? 3001);
|
const port = Number(process.env.BACKEND_PORT ?? 3001);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
await syncSystemWordingCatalog();
|
||||||
await app.listen({ host: '0.0.0.0', port });
|
await app.listen({ host: '0.0.0.0', port });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
app.log.error(error);
|
app.log.error(error);
|
||||||
|
|||||||
349
backend/src/systemWordingQueries.ts
Normal file
349
backend/src/systemWordingQueries.ts
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { defaultLocale, systemWordingCatalogEntries, systemWordingFallback, type SystemWordingTree } from '../../system-wordings.ts';
|
||||||
|
import { pool, query } from './db.ts';
|
||||||
|
|
||||||
|
type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||||
|
type SystemWordingValueRow = {
|
||||||
|
key: string;
|
||||||
|
module: string;
|
||||||
|
surface: SystemWordingSurface;
|
||||||
|
description: string;
|
||||||
|
placeholders: unknown;
|
||||||
|
value: string;
|
||||||
|
defaultValue: string;
|
||||||
|
missing: boolean;
|
||||||
|
updatedAt: Date | null;
|
||||||
|
updatedBy: { id: number; displayName: string } | null;
|
||||||
|
};
|
||||||
|
type ValidationError = Error & { statusCode: number };
|
||||||
|
|
||||||
|
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
||||||
|
const wordingKeyPattern = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
|
||||||
|
const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g;
|
||||||
|
const surfaces = new Set<SystemWordingSurface>(['frontend', 'backend', 'email']);
|
||||||
|
|
||||||
|
const legacyMessageKeys = new Map<string, string>([
|
||||||
|
['Record does not exist', 'server.validation.recordMissing'],
|
||||||
|
['Language code is invalid', 'server.validation.languageCodeInvalid'],
|
||||||
|
['Language name is required', 'server.validation.languageNameRequired'],
|
||||||
|
['Default language must be English', 'server.validation.defaultLanguageMustBeEnglish'],
|
||||||
|
['Default language must be enabled', 'server.validation.defaultLanguageMustBeEnabled'],
|
||||||
|
['Language not found', 'server.validation.languageNotFound'],
|
||||||
|
['A default language is required', 'server.validation.defaultLanguageRequired'],
|
||||||
|
['Default language cannot be deleted', 'server.validation.defaultLanguageCannotBeDeleted'],
|
||||||
|
['Please select a language', 'server.validation.selectLanguage'],
|
||||||
|
['Language does not exist', 'server.validation.languageDoesNotExist'],
|
||||||
|
['Pokemon identifier is required', 'server.validation.pokemonIdentifierRequired'],
|
||||||
|
['Pokemon type data is unavailable', 'server.validation.pokemonTypeDataUnavailable'],
|
||||||
|
['Pokemon data was not found', 'server.validation.pokemonDataNotFound'],
|
||||||
|
['Pokemon image path is invalid', 'server.validation.pokemonImagePathInvalid'],
|
||||||
|
['Please enter a task', 'server.validation.taskRequired'],
|
||||||
|
['Please select a task', 'server.validation.selectTask'],
|
||||||
|
['Task does not exist', 'server.validation.taskDoesNotExist'],
|
||||||
|
['Please enter a post', 'server.validation.postRequired'],
|
||||||
|
['Post is too long', 'server.validation.postTooLong'],
|
||||||
|
['Please enter a comment', 'server.validation.commentRequired'],
|
||||||
|
['Comment is too long', 'server.validation.commentTooLong'],
|
||||||
|
['Reaction is invalid', 'server.validation.reactionInvalid'],
|
||||||
|
['Cursor is invalid', 'server.validation.cursorInvalid'],
|
||||||
|
['Tag is invalid', 'server.validation.tagInvalid'],
|
||||||
|
['Entity type is invalid', 'server.validation.entityTypeInvalid'],
|
||||||
|
['Record is invalid', 'server.validation.recordInvalid'],
|
||||||
|
['Comment is invalid', 'server.validation.commentInvalid'],
|
||||||
|
['Please select a record', 'server.validation.selectRecord'],
|
||||||
|
['Choose at least 1 type', 'server.validation.typeMin'],
|
||||||
|
['Choose at most 2 types', 'server.validation.typeMax'],
|
||||||
|
['Choose at most 2 specialities', 'server.validation.skillMax'],
|
||||||
|
['Choose at most 6 favourites', 'server.validation.favoriteMax'],
|
||||||
|
['Drop items must be linked to selected specialities', 'server.validation.dropItemSelectedSkill'],
|
||||||
|
['Pokemon ID is required', 'server.validation.pokemonIdRequired'],
|
||||||
|
['Pokemon name is required', 'server.validation.pokemonNameRequired'],
|
||||||
|
['Height must be a non-negative number', 'server.validation.heightNonNegative'],
|
||||||
|
['Weight must be a non-negative number', 'server.validation.weightNonNegative'],
|
||||||
|
['Ideal Habitat is required', 'server.validation.environmentRequired'],
|
||||||
|
['This speciality cannot have a drop item', 'server.validation.skillNoDrop'],
|
||||||
|
['Habitat name is required', 'server.validation.habitatNameRequired'],
|
||||||
|
['Usage is required', 'server.validation.usageRequired'],
|
||||||
|
['Item name is required', 'server.validation.itemNameRequired'],
|
||||||
|
['Category is required', 'server.validation.categoryRequired'],
|
||||||
|
['An item with a recipe cannot be marked as recipe-free', 'server.validation.recipeFreeWithRecipe'],
|
||||||
|
['Item is required', 'server.validation.itemRequired'],
|
||||||
|
['This item is marked as recipe-free', 'server.validation.recipeFreeItem'],
|
||||||
|
['Name is required', 'server.validation.nameRequired']
|
||||||
|
]);
|
||||||
|
|
||||||
|
function validationError(message: string): ValidationError {
|
||||||
|
const error = new Error(message) as ValidationError;
|
||||||
|
error.statusCode = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanLocale(value: unknown): string {
|
||||||
|
const locale = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return localePattern.test(locale) ? locale : defaultLocale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLocale(value: unknown): string {
|
||||||
|
const locale = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!localePattern.test(locale)) {
|
||||||
|
throw validationError('server.wordings.localeRequired');
|
||||||
|
}
|
||||||
|
return locale;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireWordingKey(value: unknown): string {
|
||||||
|
const key = typeof value === 'string' ? value.trim() : '';
|
||||||
|
if (!wordingKeyPattern.test(key)) {
|
||||||
|
throw validationError('server.wordings.keyNotFound');
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanSurface(value: unknown): SystemWordingSurface | '' {
|
||||||
|
const surface = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return surfaces.has(surface as SystemWordingSurface) ? (surface as SystemWordingSurface) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPlaceholders(value: string): string[] {
|
||||||
|
return [...new Set([...value.matchAll(placeholderPattern)].map((match) => match[1]))].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeholdersMatch(first: string[], second: string[]): boolean {
|
||||||
|
return first.length === second.length && first.every((placeholder, index) => placeholder === second[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolate(message: string, params: Record<string, string | number>): string {
|
||||||
|
return Object.entries(params).reduce(
|
||||||
|
(nextMessage, [key, value]) => nextMessage.replaceAll(`{${key}}`, String(value)),
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setNestedMessage(target: SystemWordingTree, key: string, value: string): void {
|
||||||
|
const parts = key.split('.');
|
||||||
|
let node = target;
|
||||||
|
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
const current = node[part];
|
||||||
|
if (typeof current !== 'object' || current === null) {
|
||||||
|
node[part] = {};
|
||||||
|
}
|
||||||
|
node = node[part] as SystemWordingTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
node[parts[parts.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nestedMessages(rows: Array<{ key: string; value: string }>): SystemWordingTree {
|
||||||
|
const messages: SystemWordingTree = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
setNestedMessage(messages, row.key, row.value);
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaceholders(value: unknown): string[] {
|
||||||
|
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function legacyMessageKey(message: string): string | null {
|
||||||
|
if (message.startsWith('server.') || message.startsWith('email.')) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
if (message.endsWith(' must be a non-negative integer')) {
|
||||||
|
return 'server.validation.statNonNegative';
|
||||||
|
}
|
||||||
|
if (message.endsWith(' is empty')) {
|
||||||
|
return 'server.validation.pokemonDataFileEmpty';
|
||||||
|
}
|
||||||
|
if (message.startsWith('Pokemon data file ') && message.endsWith(' is unavailable')) {
|
||||||
|
return 'server.validation.pokemonDataFileUnavailable';
|
||||||
|
}
|
||||||
|
return legacyMessageKeys.get(message) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncSystemWordingCatalog(): Promise<void> {
|
||||||
|
const entries = systemWordingCatalogEntries();
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
for (const entry of entries) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO system_wording_keys (key, module, surface, description, placeholders)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET module = EXCLUDED.module,
|
||||||
|
surface = EXCLUDED.surface,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
placeholders = EXCLUDED.placeholders,
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[entry.key, entry.module, entry.surface, entry.description, JSON.stringify(entry.placeholders)]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const [locale, value] of Object.entries(entry.values)) {
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
INSERT INTO system_wording_values (key, locale, value)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (key, locale) DO NOTHING
|
||||||
|
`,
|
||||||
|
[entry.key, locale, value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await client.query('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function systemMessage(
|
||||||
|
locale: string,
|
||||||
|
key: string,
|
||||||
|
params: Record<string, string | number> = {}
|
||||||
|
): Promise<string> {
|
||||||
|
const requestedLocale = cleanLocale(locale);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pool.query<{ value: string }>(
|
||||||
|
`
|
||||||
|
SELECT COALESCE(requested.value, fallback.value) AS value
|
||||||
|
FROM system_wording_keys k
|
||||||
|
LEFT JOIN system_wording_values requested
|
||||||
|
ON requested.key = k.key
|
||||||
|
AND requested.locale = $2
|
||||||
|
LEFT JOIN system_wording_values fallback
|
||||||
|
ON fallback.key = k.key
|
||||||
|
AND fallback.locale = $3
|
||||||
|
WHERE k.key = $1
|
||||||
|
`,
|
||||||
|
[key, requestedLocale, defaultLocale]
|
||||||
|
);
|
||||||
|
const message = result.rows[0]?.value ?? systemWordingFallback(key, requestedLocale) ?? key;
|
||||||
|
return interpolate(message, params);
|
||||||
|
} catch {
|
||||||
|
return interpolate(systemWordingFallback(key, requestedLocale) ?? key, params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
|
||||||
|
const key = legacyMessageKey(message);
|
||||||
|
return key ? systemMessage(locale, key) : message;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSystemWordings(locale: string) {
|
||||||
|
const requestedLocale = cleanLocale(locale);
|
||||||
|
const rows = await query<{ key: string; value: string; missing: boolean }>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
k.key,
|
||||||
|
COALESCE(requested.value, fallback.value, '') AS value,
|
||||||
|
($1 <> $2 AND requested.value IS NULL) AS missing
|
||||||
|
FROM system_wording_keys k
|
||||||
|
LEFT JOIN system_wording_values requested
|
||||||
|
ON requested.key = k.key
|
||||||
|
AND requested.locale = $1
|
||||||
|
LEFT JOIN system_wording_values fallback
|
||||||
|
ON fallback.key = k.key
|
||||||
|
AND fallback.locale = $2
|
||||||
|
WHERE k.enabled = true
|
||||||
|
ORDER BY k.key
|
||||||
|
`,
|
||||||
|
[requestedLocale, defaultLocale]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
locale: requestedLocale,
|
||||||
|
fallbackLocale: defaultLocale,
|
||||||
|
messages: nestedMessages(rows),
|
||||||
|
missingKeys: rows.filter((row) => row.missing).map((row) => row.key)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSystemWordingRows(filters: Record<string, unknown>) {
|
||||||
|
const locale = cleanLocale(filters.locale);
|
||||||
|
const module = typeof filters.module === 'string' ? filters.module.trim() : '';
|
||||||
|
const surface = cleanSurface(filters.surface);
|
||||||
|
const missingOnly = filters.missing === 'true' || filters.missing === true;
|
||||||
|
|
||||||
|
return query<SystemWordingValueRow>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
k.key,
|
||||||
|
k.module,
|
||||||
|
k.surface,
|
||||||
|
k.description,
|
||||||
|
k.placeholders,
|
||||||
|
COALESCE(requested.value, '') AS value,
|
||||||
|
COALESCE(fallback.value, '') AS "defaultValue",
|
||||||
|
($1 <> $2 AND requested.value IS NULL) AS missing,
|
||||||
|
requested.updated_at AS "updatedAt",
|
||||||
|
CASE
|
||||||
|
WHEN updated_user.id IS NULL THEN NULL
|
||||||
|
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
||||||
|
END AS "updatedBy"
|
||||||
|
FROM system_wording_keys k
|
||||||
|
LEFT JOIN system_wording_values requested
|
||||||
|
ON requested.key = k.key
|
||||||
|
AND requested.locale = $1
|
||||||
|
LEFT JOIN system_wording_values fallback
|
||||||
|
ON fallback.key = k.key
|
||||||
|
AND fallback.locale = $2
|
||||||
|
LEFT JOIN users updated_user ON updated_user.id = requested.updated_by_user_id
|
||||||
|
WHERE k.enabled = true
|
||||||
|
AND ($3 = '' OR k.module = $3)
|
||||||
|
AND ($4 = '' OR k.surface = $4)
|
||||||
|
AND ($5 = false OR ($1 <> $2 AND requested.value IS NULL))
|
||||||
|
ORDER BY k.module, k.key
|
||||||
|
`,
|
||||||
|
[locale, defaultLocale, module, surface, missingOnly]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSystemWordingValue(keyValue: string, payload: Record<string, unknown>, userId: number) {
|
||||||
|
const key = requireWordingKey(keyValue);
|
||||||
|
const locale = requireLocale(payload.locale);
|
||||||
|
const value = typeof payload.value === 'string' ? payload.value.trim() : '';
|
||||||
|
|
||||||
|
const keyRow = await pool.query<{ placeholders: unknown }>('SELECT placeholders FROM system_wording_keys WHERE key = $1', [key]);
|
||||||
|
const placeholders = normalizePlaceholders(keyRow.rows[0]?.placeholders);
|
||||||
|
if (keyRow.rowCount === 0) {
|
||||||
|
throw validationError('server.wordings.keyNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (locale === defaultLocale && value === '') {
|
||||||
|
throw validationError('server.wordings.valueRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value !== '' && !placeholdersMatch(placeholders, collectPlaceholders(value))) {
|
||||||
|
throw validationError('server.wordings.placeholderMismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await pool.query<{ code: string }>('SELECT code FROM languages WHERE code = $1', [locale]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw validationError('server.wordings.localeRequired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
await pool.query('DELETE FROM system_wording_values WHERE key = $1 AND locale = $2', [key, locale]);
|
||||||
|
} else {
|
||||||
|
await pool.query(
|
||||||
|
`
|
||||||
|
INSERT INTO system_wording_values (key, locale, value, created_by_user_id, updated_by_user_id)
|
||||||
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
|
ON CONFLICT (key, locale) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value,
|
||||||
|
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[key, locale, value, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return listSystemWordingRows({ locale });
|
||||||
|
}
|
||||||
@@ -10,5 +10,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
"include": ["src/**/*.ts", "tests/**/*.ts", "../system-wordings.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ services:
|
|||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: .
|
||||||
|
dockerfile: frontend/Dockerfile
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE_URL: http://localhost:3001
|
VITE_API_BASE_URL: http://localhost:3001
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app/frontend
|
||||||
COPY package.json ./
|
COPY frontend/package.json ./
|
||||||
RUN corepack enable && pnpm install
|
RUN corepack enable && pnpm install
|
||||||
COPY . .
|
COPY frontend/. .
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
COPY system-wordings.ts /app/system-wordings.ts
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["pnpm", "run", "dev"]
|
CMD ["pnpm", "run", "dev"]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
iconPokemon,
|
iconPokemon,
|
||||||
iconRecipe
|
iconRecipe
|
||||||
} from './icons';
|
} from './icons';
|
||||||
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
|
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
|
||||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
||||||
|
|
||||||
const { t, locale } = useI18n();
|
const { t, locale } = useI18n();
|
||||||
@@ -87,12 +87,15 @@ async function loadLanguages() {
|
|||||||
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
||||||
setCurrentLocale('en');
|
setCurrentLocale('en');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadSystemWordings(getCurrentLocale());
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the built-in language list when the API is not ready yet.
|
// Keep the built-in language list when the API is not ready yet.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLocale(value: string) {
|
async function updateLocale(value: string) {
|
||||||
|
await loadSystemWordings(value);
|
||||||
setCurrentLocale(value);
|
setCurrentLocale(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { iconClose, iconLogin, iconLogout, iconMenu, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
import { iconClose, iconLogin, iconLogout, iconMenu, iconProfile, iconRegister, iconTranslate, type AppIcon } from '../icons';
|
||||||
import type { AuthUser, Language } from '../services/api';
|
import type { AuthUser, Language } from '../services/api';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
@@ -184,7 +184,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
|
<RouterLink class="auth-user" to="/profile" :aria-label="t('nav.profile')" @click="closeSidebar">
|
||||||
|
<Icon :icon="iconProfile" class="ui-icon auth-user__icon" aria-hidden="true" />
|
||||||
|
<span class="auth-user__name">{{ currentUser.displayName || currentUser.email }}</span>
|
||||||
|
</RouterLink>
|
||||||
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
|
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="requestLogout">
|
||||||
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('nav.logout') }}
|
{{ t('nav.logout') }}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
Genus: 'pages.pokemon.genus',
|
Genus: 'pages.pokemon.genus',
|
||||||
Details: 'pages.pokemon.details',
|
Details: 'pages.pokemon.details',
|
||||||
介绍: 'pages.pokemon.details',
|
介绍: 'pages.pokemon.details',
|
||||||
|
Image: 'pages.pokemon.image',
|
||||||
|
图片: 'pages.pokemon.image',
|
||||||
Height: 'pages.pokemon.height',
|
Height: 'pages.pokemon.height',
|
||||||
身高: 'pages.pokemon.height',
|
身高: 'pages.pokemon.height',
|
||||||
Weight: 'pages.pokemon.weight',
|
Weight: 'pages.pokemon.weight',
|
||||||
|
|||||||
@@ -9,13 +9,15 @@ defineProps<{
|
|||||||
to?: string;
|
to?: string;
|
||||||
icon?: AppIcon;
|
icon?: AppIcon;
|
||||||
marker?: string;
|
marker?: string;
|
||||||
|
image?: { src: string; alt: string };
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
||||||
<span class="entity-card__mark">
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -27,8 +29,9 @@ defineProps<{
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<article v-else class="entity-card">
|
<article v-else class="entity-card">
|
||||||
<span class="entity-card__mark">
|
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||||
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||||
|
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||||
<span v-else>{{ marker }}</span>
|
<span v-else>{{ marker }}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
1017
frontend/src/i18n.ts
1017
frontend/src/i18n.ts
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ export const iconEvent: AppIcon = 'mdi:calendar-star';
|
|||||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
export const iconInfo: AppIcon = 'mdi:information-outline';
|
export const iconInfo: AppIcon = 'mdi:information-outline';
|
||||||
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
|
||||||
|
export const iconKey: AppIcon = 'mdi:key-outline';
|
||||||
export const iconLife: AppIcon = 'mdi:post-outline';
|
export const iconLife: AppIcon = 'mdi:post-outline';
|
||||||
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
|
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
|
||||||
export const iconLogin: AppIcon = 'mdi:login';
|
export const iconLogin: AppIcon = 'mdi:login';
|
||||||
@@ -28,6 +29,7 @@ export const iconMail: AppIcon = 'mdi:email-fast-outline';
|
|||||||
export const iconMenu: AppIcon = 'mdi:menu';
|
export const iconMenu: AppIcon = 'mdi:menu';
|
||||||
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
export const iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||||
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
export const iconPokemon: AppIcon = 'mdi:pokeball';
|
||||||
|
export const iconProfile: AppIcon = 'mdi:account-circle-outline';
|
||||||
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
export const iconRecipe: AppIcon = 'mdi:book-open-page-variant-outline';
|
||||||
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
export const iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||||
export const iconReply: AppIcon = 'mdi:reply-outline';
|
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import DailyChecklistView from '../views/DailyChecklistView.vue';
|
|||||||
import LifeView from '../views/LifeView.vue';
|
import LifeView from '../views/LifeView.vue';
|
||||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
|
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
|
import UserProfileView from '../views/UserProfileView.vue';
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
import RegisterView from '../views/RegisterView.vue';
|
||||||
|
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||||
|
|
||||||
@@ -44,7 +47,10 @@ export const router = createRouter({
|
|||||||
{ path: '/checklist', component: DailyChecklistView },
|
{ path: '/checklist', component: DailyChecklistView },
|
||||||
{ path: '/life', component: LifeView },
|
{ path: '/life', component: LifeView },
|
||||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||||
|
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
|
||||||
{ path: '/login', component: LoginView },
|
{ path: '/login', component: LoginView },
|
||||||
|
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||||
|
{ path: '/reset-password', component: ResetPasswordView },
|
||||||
{ path: '/register', component: RegisterView },
|
{ path: '/register', component: RegisterView },
|
||||||
{ path: '/verify-email', component: VerifyEmailView }
|
{ path: '/verify-email', component: VerifyEmailView }
|
||||||
],
|
],
|
||||||
@@ -56,7 +62,10 @@ export const router = createRouter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
if (!to.matched.some((record) => record.meta.requiresVerified === true)) {
|
const requiresVerified = to.matched.some((record) => record.meta.requiresVerified === true);
|
||||||
|
const requiresAuth = requiresVerified || to.matched.some((record) => record.meta.requiresAuth === true);
|
||||||
|
|
||||||
|
if (!requiresAuth) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +75,7 @@ router.beforeEach(async (to) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.me();
|
const response = await api.me();
|
||||||
return response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
|
return !requiresVerified || response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
|
||||||
} catch {
|
} catch {
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
return { path: '/login', query: { redirect: to.fullPath } };
|
return { path: '/login', query: { redirect: to.fullPath } };
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ export interface Language {
|
|||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||||
|
|
||||||
|
export interface SystemWording {
|
||||||
|
key: string;
|
||||||
|
module: string;
|
||||||
|
surface: SystemWordingSurface;
|
||||||
|
description: string;
|
||||||
|
placeholders: string[];
|
||||||
|
value: string;
|
||||||
|
defaultValue: string;
|
||||||
|
missing: boolean;
|
||||||
|
updatedAt: string | null;
|
||||||
|
updatedBy: UserSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface NamedEntity {
|
export interface NamedEntity {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,6 +50,15 @@ export interface PokemonStats {
|
|||||||
speed: number;
|
speed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PokemonImage {
|
||||||
|
path: string;
|
||||||
|
url: string;
|
||||||
|
style: string;
|
||||||
|
version: string;
|
||||||
|
variant: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserSummary {
|
export interface UserSummary {
|
||||||
id: number;
|
id: number;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
@@ -74,6 +98,7 @@ export interface Pokemon extends EditInfo {
|
|||||||
heightMeters: number;
|
heightMeters: number;
|
||||||
weightPounds: number;
|
weightPounds: number;
|
||||||
weightKg: number;
|
weightKg: number;
|
||||||
|
image: PokemonImage | null;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
types: NamedEntity[];
|
types: NamedEntity[];
|
||||||
stats: PokemonStats;
|
stats: PokemonStats;
|
||||||
@@ -249,9 +274,14 @@ export interface AuthUser {
|
|||||||
emailVerified: boolean;
|
emailVerified: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserProfilePayload {
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterPayload extends LoginPayload {
|
export interface RegisterPayload extends LoginPayload {
|
||||||
@@ -288,6 +318,7 @@ export interface PokemonPayload {
|
|||||||
skillIds: number[];
|
skillIds: number[];
|
||||||
favoriteThingIds: number[];
|
favoriteThingIds: number[];
|
||||||
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
||||||
|
imagePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PokemonFetchResult {
|
export interface PokemonFetchResult {
|
||||||
@@ -308,6 +339,12 @@ export interface PokemonFetchOption {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PokemonImageOptionsResult {
|
||||||
|
id: number;
|
||||||
|
identifier: string;
|
||||||
|
images: PokemonImage[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemPayload {
|
export interface ItemPayload {
|
||||||
name: string;
|
name: string;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
@@ -386,26 +423,37 @@ export function buildQuery(params: Record<string, string | number | undefined>):
|
|||||||
return query ? `?${query}` : '';
|
return query ? `?${query}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthToken(): string | null {
|
function authStorage(type: 'local' | 'session'): Storage | null {
|
||||||
if (typeof localStorage === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return localStorage.getItem(authTokenKey);
|
return type === 'local' ? window.localStorage : window.sessionStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuthToken(token: string | null): void {
|
export function getAuthToken(): string | null {
|
||||||
if (typeof localStorage === 'undefined') {
|
const sessionToken = authStorage('session')?.getItem(authTokenKey);
|
||||||
return;
|
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
|
||||||
|
const local = authStorage('local');
|
||||||
|
const session = authStorage('session');
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
localStorage.setItem(authTokenKey, token);
|
if (options.persistent === false) {
|
||||||
|
session?.setItem(authTokenKey, token);
|
||||||
|
local?.removeItem(authTokenKey);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(authTokenKey);
|
local?.setItem(authTokenKey, token);
|
||||||
|
session?.removeItem(authTokenKey);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
local?.removeItem(authTokenKey);
|
||||||
|
session?.removeItem(authTokenKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.dispatchEvent(new Event(authChangeEvent));
|
notifyAuthChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onAuthTokenChange(callback: () => void): () => void {
|
export function onAuthTokenChange(callback: () => void): () => void {
|
||||||
@@ -413,6 +461,12 @@ export function onAuthTokenChange(callback: () => void): () => void {
|
|||||||
return () => window.removeEventListener(authChangeEvent, callback);
|
return () => window.removeEventListener(authChangeEvent, callback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function notifyAuthChange(): void {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new Event(authChangeEvent));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function requestHeaders(): HeadersInit {
|
function requestHeaders(): HeadersInit {
|
||||||
const token = getAuthToken();
|
const token = getAuthToken();
|
||||||
return {
|
return {
|
||||||
@@ -447,7 +501,7 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown): Promise<T> {
|
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
|
||||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -508,11 +562,20 @@ export const api = {
|
|||||||
sendJson<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
|
sendJson<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
|
||||||
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
|
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
|
||||||
deleteLanguage: (code: string) => deleteJson(`/api/admin/languages/${code}`),
|
deleteLanguage: (code: string) => deleteJson(`/api/admin/languages/${code}`),
|
||||||
|
systemWordings: (params: { locale?: string; module?: string; surface?: string; missing?: string } = {}) =>
|
||||||
|
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
|
||||||
|
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
|
||||||
|
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
|
||||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||||
verifyEmail: (token: string) =>
|
verifyEmail: (token: string) =>
|
||||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||||
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
|
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
|
||||||
|
requestPasswordReset: (payload: { email: string }) =>
|
||||||
|
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
|
||||||
|
resetPassword: (payload: { token: string; password: string }) =>
|
||||||
|
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
|
||||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
|
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||||
@@ -572,6 +635,8 @@ export const api = {
|
|||||||
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
|
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
|
||||||
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
|
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
|
||||||
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
|
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
|
||||||
|
fetchPokemonImageOptions: (identifier: string) =>
|
||||||
|
sendJson<PokemonImageOptionsResult>('/api/pokemon/image-options', 'POST', { identifier }),
|
||||||
createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload),
|
createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload),
|
||||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||||
|
|||||||
@@ -364,11 +364,43 @@ svg {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.auth-user {
|
.auth-user {
|
||||||
|
min-height: 44px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition:
|
||||||
|
background 0.14s ease,
|
||||||
|
border-color 0.14s ease,
|
||||||
|
color 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-user:hover,
|
||||||
|
.auth-user.router-link-active {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
background: rgba(255, 203, 5, 0.22);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-user__icon {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-user__name {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -751,6 +783,13 @@ button:disabled,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-fetch-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-fetch-panel__button {
|
.pokemon-fetch-panel__button {
|
||||||
min-width: 118px;
|
min-width: 118px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -812,6 +851,115 @@ button:disabled,
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-image-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-preview {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 4px solid #172036;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
#eef9ff;
|
||||||
|
color: #172036;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-preview__screen {
|
||||||
|
min-height: 220px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid rgba(23, 32, 54, 0.18);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 203, 5, 0.24), rgba(42, 117, 187, 0.12)),
|
||||||
|
#ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-preview__screen img {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-preview__caption {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-preview__caption strong {
|
||||||
|
color: #172036;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-preview__caption span {
|
||||||
|
color: #354052;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-preview__caption p {
|
||||||
|
margin: 0;
|
||||||
|
color: #354052;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-thumbnails {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-thumbnail {
|
||||||
|
min-height: 128px;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 2px 0 var(--line-strong);
|
||||||
|
color: var(--ink);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-thumbnail:hover,
|
||||||
|
.pokemon-image-thumbnail:focus-visible {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-thumbnail.active {
|
||||||
|
background: color-mix(in srgb, var(--pokemon-yellow) 24%, var(--surface));
|
||||||
|
border-color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-thumbnail img {
|
||||||
|
width: 86px;
|
||||||
|
height: 76px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-thumbnail span {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: center;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-clear {
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-edit-panel {
|
.pokemon-edit-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -1305,11 +1453,24 @@ button:disabled,
|
|||||||
font-weight: 950;
|
font-weight: 950;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-card__mark--image {
|
||||||
|
padding: 3px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 203, 5, 0.22), rgba(42, 117, 187, 0.12)),
|
||||||
|
#ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-card__icon {
|
.entity-card__icon {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-card__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-card__content {
|
.entity-card__content {
|
||||||
display: grid;
|
display: grid;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
@@ -1332,6 +1493,33 @@ button:disabled,
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-list-grid .entity-card {
|
||||||
|
min-height: 168px;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
justify-items: center;
|
||||||
|
align-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-list-grid .entity-card__mark {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-list-grid .pokeball-mark {
|
||||||
|
--ball-size: 64px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-list-grid .entity-card__content {
|
||||||
|
justify-items: center;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-list-grid .entity-card__title {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-meta {
|
.edit-meta {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -2409,6 +2597,133 @@ button:disabled,
|
|||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-wording-header {
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-header__locale {
|
||||||
|
width: min(260px, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar__title {
|
||||||
|
padding: 2px 4px 4px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar__button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-weight: 850;
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar__button:hover {
|
||||||
|
border-color: rgba(42, 117, 187, 0.24);
|
||||||
|
background: rgba(255, 203, 5, 0.2);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar__button.active {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
background: var(--pokemon-blue);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 2px 0 var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar__button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-content {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-controls .tabs--component {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-toolbar__check {
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-list li {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-row {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-row strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 13px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-row__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-row__meta .config-flag {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-row__value {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 14px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.chips {
|
.chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -3064,6 +3379,58 @@ button:disabled,
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-image-detail {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-detail__screen {
|
||||||
|
min-height: 260px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 4px solid #172036;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
#eef9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-detail__screen img {
|
||||||
|
width: min(100%, 380px);
|
||||||
|
max-height: 250px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-detail__caption {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-detail__caption strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-detail__caption span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-image-detail__caption p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-profile-grid {
|
.pokemon-profile-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||||
@@ -3071,6 +3438,21 @@ button:disabled,
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-grid--with-image {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(430px, 560px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-side {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-side--with-image {
|
||||||
|
grid-template-columns: minmax(0, 1fr) clamp(112px, 12vw, 164px);
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-profile-main,
|
.pokemon-profile-main,
|
||||||
.pokemon-profile-row {
|
.pokemon-profile-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -3092,6 +3474,36 @@ button:disabled,
|
|||||||
align-self: stretch;
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-image {
|
||||||
|
width: clamp(112px, 12vw, 164px);
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: center;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
border: 4px solid #172036;
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||||
|
#eef9ff;
|
||||||
|
box-shadow: var(--shadow-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-image:hover,
|
||||||
|
.pokemon-profile-image:focus-visible {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
.pokemon-types-card {
|
.pokemon-types-card {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
@@ -3404,6 +3816,27 @@ button:disabled,
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-options__remember {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-options a {
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
font-weight: 900;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.auth-switch {
|
.auth-switch {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
@@ -3429,6 +3862,103 @@ button:disabled,
|
|||||||
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
background: color-mix(in srgb, var(--danger) 10%, var(--surface));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(260px, 0.62fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 18px;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card--identity {
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 3px solid var(--line-strong);
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
linear-gradient(to bottom, var(--pokemon-red) 0 45%, var(--line-strong) 45% 55%, var(--pokeball-white) 55% 100%);
|
||||||
|
color: var(--line-strong);
|
||||||
|
box-shadow: inset 0 3px 0 rgba(255, 255, 255, 0.38), 0 3px 0 rgba(0, 0, 0, 0.16);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 23px;
|
||||||
|
font-weight: 950;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity__copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity h2,
|
||||||
|
.profile-card__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 950;
|
||||||
|
line-height: 1.1;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-identity p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 800;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-card__icon {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--pokemon-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-field-note {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-readonly-input {
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||||
@@ -3748,13 +4278,27 @@ button:disabled,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
|
.pokemon-image-detail,
|
||||||
.pokemon-profile-grid,
|
.pokemon-profile-grid,
|
||||||
.pokemon-profile-row,
|
.pokemon-profile-row,
|
||||||
.pokemon-related-grid,
|
.pokemon-related-grid,
|
||||||
|
.profile-layout,
|
||||||
|
.system-wording-layout,
|
||||||
.admin-layout {
|
.admin-layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar {
|
||||||
|
display: flex;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-sidebar__button {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.coming-soon-panel {
|
.coming-soon-panel {
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
@@ -3812,6 +4356,20 @@ button:disabled,
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pokemon-fetch-panel__actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-side--with-image {
|
||||||
|
grid-template-columns: minmax(0, 1fr) clamp(96px, 21vw, 132px);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pokemon-profile-image {
|
||||||
|
width: clamp(96px, 21vw, 132px);
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.coming-soon-panel {
|
.coming-soon-panel {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -3840,6 +4398,32 @@ button:disabled,
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-wording-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-header__locale {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-controls .tabs--component {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-toolbar__check {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-list li {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-wording-list .row-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.life-feed__list {
|
.life-feed__list {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
iconTranslate,
|
iconTranslate,
|
||||||
type AppIcon
|
type AppIcon
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import { defaultLocale, getCurrentLocale, setCurrentLocale } from '../i18n';
|
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
@@ -37,15 +37,18 @@ import {
|
|||||||
type Pokemon,
|
type Pokemon,
|
||||||
type Recipe,
|
type Recipe,
|
||||||
type Skill,
|
type Skill,
|
||||||
|
type SystemWording,
|
||||||
|
type SystemWordingSurface,
|
||||||
type TranslationMap
|
type TranslationMap
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type AdminTab = 'config' | 'languages' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type AdminTab = 'config' | 'languages' | 'wordings' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||||
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
||||||
|
|
||||||
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||||
config: iconAdmin,
|
config: iconAdmin,
|
||||||
languages: iconTranslate,
|
languages: iconTranslate,
|
||||||
|
wordings: iconTranslate,
|
||||||
checklist: iconChecklist,
|
checklist: iconChecklist,
|
||||||
pokemon: iconPokemon,
|
pokemon: iconPokemon,
|
||||||
items: iconItem,
|
items: iconItem,
|
||||||
@@ -58,6 +61,7 @@ const { locale, t } = useI18n();
|
|||||||
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||||
{ key: 'config', label: t('pages.admin.config') },
|
{ key: 'config', label: t('pages.admin.config') },
|
||||||
{ key: 'languages', label: t('pages.admin.languages') },
|
{ key: 'languages', label: t('pages.admin.languages') },
|
||||||
|
{ key: 'wordings', label: t('pages.admin.wordings') },
|
||||||
{ key: 'checklist', label: t('pages.admin.checklist') },
|
{ key: 'checklist', label: t('pages.admin.checklist') },
|
||||||
{ key: 'pokemon', label: 'Pokemon' },
|
{ key: 'pokemon', label: 'Pokemon' },
|
||||||
{ key: 'items', label: t('pages.items.title') },
|
{ key: 'items', label: t('pages.items.title') },
|
||||||
@@ -86,6 +90,7 @@ const pokemonRows = ref<Pokemon[]>([]);
|
|||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
const recipeRows = ref<Recipe[]>([]);
|
const recipeRows = ref<Recipe[]>([]);
|
||||||
const habitatRows = ref<Habitat[]>([]);
|
const habitatRows = ref<Habitat[]>([]);
|
||||||
|
const wordingRows = ref<SystemWording[]>([]);
|
||||||
const currentUser = ref<AuthUser | null>(null);
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const contentLoading = ref(false);
|
const contentLoading = ref(false);
|
||||||
@@ -93,10 +98,16 @@ const message = ref('');
|
|||||||
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
|
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
|
||||||
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||||
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
||||||
|
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
||||||
const editingLanguageCode = ref('');
|
const editingLanguageCode = ref('');
|
||||||
const configModalOpen = ref(false);
|
const configModalOpen = ref(false);
|
||||||
const checklistModalOpen = ref(false);
|
const checklistModalOpen = ref(false);
|
||||||
const languageModalOpen = ref(false);
|
const languageModalOpen = ref(false);
|
||||||
|
const wordingModalOpen = ref(false);
|
||||||
|
const wordingLocale = ref(getCurrentLocale());
|
||||||
|
const wordingModule = ref('');
|
||||||
|
const wordingSurface = ref<SystemWordingSurface | ''>('');
|
||||||
|
const wordingMissingOnly = ref(false);
|
||||||
|
|
||||||
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
|
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
|
||||||
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
|
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
|
||||||
@@ -140,6 +151,36 @@ const configModalTitle = computed(() =>
|
|||||||
);
|
);
|
||||||
const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask')));
|
const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask')));
|
||||||
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
||||||
|
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
||||||
|
const wordingLocaleOptions = computed(() =>
|
||||||
|
languageRows.value.length
|
||||||
|
? languageRows.value
|
||||||
|
: [
|
||||||
|
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
||||||
|
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const wordingModules = computed(() => [...new Set(wordingRows.value.map((item) => item.module))].sort((a, b) => a.localeCompare(b)));
|
||||||
|
const wordingSurfaceTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: '', label: t('pages.admin.allSurfaces') },
|
||||||
|
{ value: 'frontend', label: t('pages.admin.surfaceFrontend') },
|
||||||
|
{ value: 'backend', label: t('pages.admin.surfaceBackend') },
|
||||||
|
{ value: 'email', label: t('pages.admin.surfaceEmail') }
|
||||||
|
]);
|
||||||
|
const activeWordingSurfaceTab = computed({
|
||||||
|
get: () => wordingSurface.value,
|
||||||
|
set: (value: string) => {
|
||||||
|
wordingSurface.value = value === 'frontend' || value === 'backend' || value === 'email' ? value : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const filteredWordingRows = computed(() =>
|
||||||
|
wordingRows.value.filter((item) => {
|
||||||
|
if (wordingModule.value && item.module !== wordingModule.value) return false;
|
||||||
|
if (wordingSurface.value && item.surface !== wordingSurface.value) return false;
|
||||||
|
if (wordingMissingOnly.value && !item.missing) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
const checklistKey = (item: DailyChecklistItem) => item.id;
|
const checklistKey = (item: DailyChecklistItem) => item.id;
|
||||||
const checklistLabel = (item: DailyChecklistItem) => item.title;
|
const checklistLabel = (item: DailyChecklistItem) => item.title;
|
||||||
const languageKey = (item: Language) => item.code;
|
const languageKey = (item: Language) => item.code;
|
||||||
@@ -197,6 +238,14 @@ function resetLanguageForm() {
|
|||||||
editingLanguageCode.value = '';
|
editingLanguageCode.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetWordingForm() {
|
||||||
|
wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWordingModule(module: string) {
|
||||||
|
wordingModule.value = module;
|
||||||
|
}
|
||||||
|
|
||||||
function openNewConfig() {
|
function openNewConfig() {
|
||||||
resetConfigForm();
|
resetConfigForm();
|
||||||
configModalOpen.value = true;
|
configModalOpen.value = true;
|
||||||
@@ -237,6 +286,11 @@ function closeLanguageModal() {
|
|||||||
resetLanguageForm();
|
resetLanguageForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeWordingModal() {
|
||||||
|
wordingModalOpen.value = false;
|
||||||
|
resetWordingForm();
|
||||||
|
}
|
||||||
|
|
||||||
function editLanguage(item: Language) {
|
function editLanguage(item: Language) {
|
||||||
editingLanguageCode.value = item.code;
|
editingLanguageCode.value = item.code;
|
||||||
languageForm.value = {
|
languageForm.value = {
|
||||||
@@ -249,6 +303,17 @@ function editLanguage(item: Language) {
|
|||||||
languageModalOpen.value = true;
|
languageModalOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editWording(item: SystemWording) {
|
||||||
|
wordingForm.value = {
|
||||||
|
key: item.key,
|
||||||
|
locale: wordingLocale.value || defaultLocale,
|
||||||
|
value: item.value,
|
||||||
|
defaultValue: item.defaultValue,
|
||||||
|
placeholders: item.placeholders
|
||||||
|
};
|
||||||
|
wordingModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
function updateConfigTranslation(localeCode: string, value: string) {
|
function updateConfigTranslation(localeCode: string, value: string) {
|
||||||
const nextTranslations: TranslationMap = { ...configForm.value.translations };
|
const nextTranslations: TranslationMap = { ...configForm.value.translations };
|
||||||
const nextFields = { ...(nextTranslations[localeCode] ?? {}) };
|
const nextFields = { ...(nextTranslations[localeCode] ?? {}) };
|
||||||
@@ -456,6 +521,7 @@ async function saveLanguage() {
|
|||||||
? await api.updateLanguage(editingLanguageCode.value, payload)
|
? await api.updateLanguage(editingLanguageCode.value, payload)
|
||||||
: await api.createLanguage(payload);
|
: await api.createLanguage(payload);
|
||||||
closeLanguageModal();
|
closeLanguageModal();
|
||||||
|
await loadSystemWordings(getCurrentLocale(), true);
|
||||||
setCurrentLocale(getCurrentLocale());
|
setCurrentLocale(getCurrentLocale());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -484,6 +550,32 @@ async function loadHabitats() {
|
|||||||
habitatRows.value = await api.habitats();
|
habitatRows.value = await api.habitats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadWordings() {
|
||||||
|
await loadLanguages();
|
||||||
|
if (!wordingLocaleOptions.value.some((language) => language.code === wordingLocale.value)) {
|
||||||
|
wordingLocale.value = defaultLocale;
|
||||||
|
}
|
||||||
|
wordingRows.value = await api.systemWordings({ locale: wordingLocale.value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadWordings() {
|
||||||
|
await run(loadWordings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWording() {
|
||||||
|
await run(async () => {
|
||||||
|
wordingRows.value = await api.updateSystemWording(wordingForm.value.key, {
|
||||||
|
locale: wordingForm.value.locale,
|
||||||
|
value: wordingForm.value.value
|
||||||
|
});
|
||||||
|
await loadSystemWordings(wordingForm.value.locale, true);
|
||||||
|
if (wordingForm.value.locale === getCurrentLocale()) {
|
||||||
|
setCurrentLocale(getCurrentLocale());
|
||||||
|
}
|
||||||
|
closeWordingModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCurrentTab(showSkeleton = false) {
|
async function loadCurrentTab(showSkeleton = false) {
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
contentLoading.value = true;
|
contentLoading.value = true;
|
||||||
@@ -492,6 +584,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
|||||||
try {
|
try {
|
||||||
if (activeTab.value === 'config') await loadConfig();
|
if (activeTab.value === 'config') await loadConfig();
|
||||||
if (activeTab.value === 'languages') await loadLanguages();
|
if (activeTab.value === 'languages') await loadLanguages();
|
||||||
|
if (activeTab.value === 'wordings') await loadWordings();
|
||||||
if (activeTab.value === 'checklist') await loadChecklist();
|
if (activeTab.value === 'checklist') await loadChecklist();
|
||||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||||
if (activeTab.value === 'items') await loadItems();
|
if (activeTab.value === 'items') await loadItems();
|
||||||
@@ -739,6 +832,79 @@ onMounted(() => {
|
|||||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'wordings'" class="detail-section">
|
||||||
|
<div class="detail-section__header system-wording-header">
|
||||||
|
<h2>{{ t('pages.admin.wordings') }}</h2>
|
||||||
|
<div class="field system-wording-header__locale">
|
||||||
|
<label for="wording-locale">{{ t('pages.admin.wordingLocale') }}</label>
|
||||||
|
<select id="wording-locale" v-model="wordingLocale" :disabled="busy" @change="reloadWordings">
|
||||||
|
<option v-for="language in wordingLocaleOptions" :key="language.code" :value="language.code">
|
||||||
|
{{ language.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="system-wording-layout">
|
||||||
|
<nav class="system-wording-sidebar" :aria-label="t('pages.admin.wordingModule')">
|
||||||
|
<span class="system-wording-sidebar__title">{{ t('pages.admin.wordingModule') }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="system-wording-sidebar__button"
|
||||||
|
:class="{ active: !wordingModule }"
|
||||||
|
:aria-current="!wordingModule ? 'true' : undefined"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="selectWordingModule('')"
|
||||||
|
>
|
||||||
|
{{ t('pages.admin.allModules') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="module in wordingModules"
|
||||||
|
:key="module"
|
||||||
|
type="button"
|
||||||
|
class="system-wording-sidebar__button"
|
||||||
|
:class="{ active: wordingModule === module }"
|
||||||
|
:aria-current="wordingModule === module ? 'true' : undefined"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="selectWordingModule(module)"
|
||||||
|
>
|
||||||
|
{{ module }}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="system-wording-content">
|
||||||
|
<div class="system-wording-controls">
|
||||||
|
<Tabs id="admin-wording-surface" v-model="activeWordingSurfaceTab" :tabs="wordingSurfaceTabs" :label="t('pages.admin.wordingSurface')" />
|
||||||
|
<div class="check-row system-wording-toolbar__check">
|
||||||
|
<label>
|
||||||
|
<input v-model="wordingMissingOnly" type="checkbox" :disabled="busy" />
|
||||||
|
{{ t('pages.admin.wordingMissingOnly') }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul v-if="filteredWordingRows.length" class="row-list system-wording-list">
|
||||||
|
<li v-for="item in filteredWordingRows" :key="item.key">
|
||||||
|
<span class="system-wording-row">
|
||||||
|
<strong>{{ item.key }}</strong>
|
||||||
|
<span class="system-wording-row__meta">
|
||||||
|
<span class="config-flag">{{ item.module }}</span>
|
||||||
|
<span class="config-flag">{{ t(`pages.admin.surface${item.surface.charAt(0).toUpperCase()}${item.surface.slice(1)}`) }}</span>
|
||||||
|
<span v-if="item.missing" class="config-flag">{{ t('pages.admin.missingTranslation') }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="system-wording-row__value">{{ item.value || item.defaultValue }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-actions">
|
||||||
|
<button type="button" :disabled="busy" @click="editWording(item)">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||||
<ReorderableList
|
<ReorderableList
|
||||||
@@ -932,5 +1098,39 @@ onMounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal v-if="wordingModalOpen" :title="wordingModalTitle" :close-label="t('common.close')" size="wide" @close="closeWordingModal">
|
||||||
|
<form id="admin-wording-form" class="modal-edit-form" @submit.prevent="saveWording">
|
||||||
|
<div class="field">
|
||||||
|
<label for="wording-key">{{ t('pages.admin.wordingKey') }}</label>
|
||||||
|
<input id="wording-key" :value="wordingForm.key" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="wording-default-value">{{ t('pages.admin.defaultValue') }}</label>
|
||||||
|
<textarea id="wording-default-value" :value="wordingForm.defaultValue" disabled></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="wordingForm.placeholders.length" class="field">
|
||||||
|
<span class="field-label">{{ t('pages.admin.placeholders') }}</span>
|
||||||
|
<span class="chips">
|
||||||
|
<span v-for="placeholder in wordingForm.placeholders" :key="placeholder" class="chip">{{ placeholder }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="wording-value">{{ t('pages.admin.wordingValue') }}</label>
|
||||||
|
<textarea id="wording-value" v-model="wordingForm.value" :required="wordingForm.locale === defaultLocale"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="submit" form="admin-wording-form" class="link-button" :disabled="busy">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="plain-button" :disabled="busy" @click="closeWordingModal">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
59
frontend/src/views/ForgotPasswordView.vue
Normal file
59
frontend/src/views/ForgotPasswordView.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import { iconMail } from '../icons';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const email = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
async function submitResetRequest() {
|
||||||
|
busy.value = true;
|
||||||
|
message.value = '';
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.requestPasswordReset({ email: email.value });
|
||||||
|
message.value = response.message;
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.requestResetFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="auth-page">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<PageHeader :title="t('auth.requestResetTitle')" :subtitle="t('auth.requestResetSubtitle')">
|
||||||
|
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<form class="auth-form" @submit.prevent="submitResetRequest">
|
||||||
|
<div class="field">
|
||||||
|
<label for="forgot-password-email">{{ t('auth.email') }}</label>
|
||||||
|
<input id="forgot-password-email" v-model="email" autocomplete="email" required type="email" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||||
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
|
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||||
|
<Icon :icon="iconMail" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('auth.sending') : t('auth.sendResetLink') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="auth-switch">
|
||||||
|
<RouterLink to="/login">{{ t('auth.goLogin') }}</RouterLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -13,6 +13,7 @@ const router = useRouter();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
const rememberMe = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
|
||||||
@@ -23,9 +24,10 @@ async function submitLogin() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.login({
|
const response = await api.login({
|
||||||
email: email.value,
|
email: email.value,
|
||||||
password: password.value
|
password: password.value,
|
||||||
|
rememberMe: rememberMe.value
|
||||||
});
|
});
|
||||||
setAuthToken(response.token);
|
setAuthToken(response.token, { persistent: rememberMe.value });
|
||||||
|
|
||||||
const redirect =
|
const redirect =
|
||||||
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||||
@@ -44,7 +46,7 @@ async function submitLogin() {
|
|||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<div class="auth-panel">
|
<div class="auth-panel">
|
||||||
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
|
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
|
||||||
<template #kicker>Trainer Pass</template>
|
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<form class="auth-form" @submit.prevent="submitLogin">
|
<form class="auth-form" @submit.prevent="submitLogin">
|
||||||
@@ -58,6 +60,14 @@ async function submitLogin() {
|
|||||||
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
|
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-options">
|
||||||
|
<label class="check-row auth-options__remember">
|
||||||
|
<input v-model="rememberMe" type="checkbox" />
|
||||||
|
{{ t('auth.rememberMe') }}
|
||||||
|
</label>
|
||||||
|
<RouterLink to="/forgot-password">{{ t('auth.forgotPassword') }}</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import DetailSection from '../components/DetailSection.vue';
|
|||||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import Modal from '../components/Modal.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||||
import Skeleton from '../components/Skeleton.vue';
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
@@ -21,6 +22,7 @@ const pokemon = ref<PokemonDetail | null>(null);
|
|||||||
const itemCategoryTab = ref('');
|
const itemCategoryTab = ref('');
|
||||||
const relatedHabitatTab = ref('');
|
const relatedHabitatTab = ref('');
|
||||||
const detailTab = ref('details');
|
const detailTab = ref('details');
|
||||||
|
const imageModalOpen = ref(false);
|
||||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||||
const weathers = ['晴天', '阴天', '雨天'];
|
const weathers = ['晴天', '阴天', '雨天'];
|
||||||
const relatedPokemonLimit = 6;
|
const relatedPokemonLimit = 6;
|
||||||
@@ -184,6 +186,22 @@ function pokemonTypeIconSrc(typeId: number): string | null {
|
|||||||
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pokemonImageAlt() {
|
||||||
|
return pokemon.value?.image ? t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant }) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonImageLabel() {
|
||||||
|
return pokemon.value?.image ? `${pokemon.value.image.version} - ${pokemon.value.image.variant}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageModal() {
|
||||||
|
imageModalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImageModal() {
|
||||||
|
imageModalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadPokemonDetail() {
|
async function loadPokemonDetail() {
|
||||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||||
pokemon.value = nextPokemon;
|
pokemon.value = nextPokemon;
|
||||||
@@ -209,6 +227,7 @@ watch(
|
|||||||
pokemon.value = null;
|
pokemon.value = null;
|
||||||
relatedHabitatTab.value = '';
|
relatedHabitatTab.value = '';
|
||||||
detailTab.value = 'details';
|
detailTab.value = 'details';
|
||||||
|
imageModalOpen.value = false;
|
||||||
void loadPokemonDetail();
|
void loadPokemonDetail();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -290,7 +309,7 @@ watch(
|
|||||||
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||||
<div class="pokemon-profile-grid">
|
<div class="pokemon-profile-grid" :class="{ 'pokemon-profile-grid--with-image': pokemon.image }">
|
||||||
<div class="pokemon-profile-main">
|
<div class="pokemon-profile-main">
|
||||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
||||||
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
||||||
@@ -333,9 +352,15 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pokemon-profile-side" :class="{ 'pokemon-profile-side--with-image': pokemon.image }">
|
||||||
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
|
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
|
||||||
<PokemonStatsPanel :stats="pokemon.stats" />
|
<PokemonStatsPanel :stats="pokemon.stats" />
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|
||||||
|
<button v-if="pokemon.image" type="button" class="pokemon-profile-image" :aria-label="pokemonImageLabel()" @click="openImageModal">
|
||||||
|
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailSection :title="t('pages.pokemon.skills')">
|
<DetailSection :title="t('pages.pokemon.skills')">
|
||||||
@@ -463,5 +488,25 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
v-if="pokemon?.image && imageModalOpen"
|
||||||
|
:title="t('pages.pokemon.image')"
|
||||||
|
:subtitle="pokemonImageLabel()"
|
||||||
|
:close-label="t('common.close')"
|
||||||
|
size="wide"
|
||||||
|
@close="closeImageModal"
|
||||||
|
>
|
||||||
|
<div class="pokemon-image-detail">
|
||||||
|
<div class="pokemon-image-detail__screen">
|
||||||
|
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
|
||||||
|
</div>
|
||||||
|
<div class="pokemon-image-detail__caption">
|
||||||
|
<strong>{{ pokemonImageLabel() }}</strong>
|
||||||
|
<span>{{ pokemon.image.style }}</span>
|
||||||
|
<p>{{ pokemon.image.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<PokemonEdit v-if="showEditor" />
|
<PokemonEdit v-if="showEditor" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
type Options,
|
type Options,
|
||||||
type PokemonFetchOption,
|
type PokemonFetchOption,
|
||||||
type PokemonFetchResult,
|
type PokemonFetchResult,
|
||||||
|
type PokemonImage,
|
||||||
type PokemonPayload,
|
type PokemonPayload,
|
||||||
type PokemonStats,
|
type PokemonStats,
|
||||||
type TranslationMap
|
type TranslationMap
|
||||||
@@ -38,11 +39,14 @@ const languages = ref<Language[]>([]);
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const fetchBusy = ref(false);
|
const fetchBusy = ref(false);
|
||||||
|
const imageBusy = ref(false);
|
||||||
const fetchOptionsLoading = ref(false);
|
const fetchOptionsLoading = ref(false);
|
||||||
const fetchOptionsOpen = ref(false);
|
const fetchOptionsOpen = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const fetchIdentifier = ref('');
|
const fetchIdentifier = ref('');
|
||||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||||
|
const imageOptions = ref<PokemonImage[]>([]);
|
||||||
|
const currentPokemonImage = ref<PokemonImage | null>(null);
|
||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
const activeEditTab = ref('basic');
|
const activeEditTab = ref('basic');
|
||||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||||
@@ -73,7 +77,8 @@ const pokemonForm = ref({
|
|||||||
environmentId: '',
|
environmentId: '',
|
||||||
skillIds: [] as string[],
|
skillIds: [] as string[],
|
||||||
favoriteThingIds: [] as string[],
|
favoriteThingIds: [] as string[],
|
||||||
skillItemDrops: [] as SkillItemDropForm[]
|
skillItemDrops: [] as SkillItemDropForm[],
|
||||||
|
imagePath: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
@@ -97,6 +102,22 @@ const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFe
|
|||||||
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
|
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
|
||||||
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
||||||
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
|
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
|
||||||
|
const selectedPokemonImage = computed(() => {
|
||||||
|
const imagePath = pokemonForm.value.imagePath;
|
||||||
|
if (!imagePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageOptions.value.find((image) => image.path === imagePath) ?? (currentPokemonImage.value?.path === imagePath ? currentPokemonImage.value : null);
|
||||||
|
});
|
||||||
|
const displayedImageOptions = computed(() => {
|
||||||
|
const selectedImage = selectedPokemonImage.value;
|
||||||
|
if (!selectedImage || imageOptions.value.some((image) => image.path === selectedImage.path)) {
|
||||||
|
return imageOptions.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [selectedImage, ...imageOptions.value];
|
||||||
|
});
|
||||||
|
|
||||||
function toIds(values: string[]): number[] {
|
function toIds(values: string[]): number[] {
|
||||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
@@ -261,8 +282,11 @@ async function loadEditor() {
|
|||||||
skillItemDrops: pokemon.skills.map((skill) => ({
|
skillItemDrops: pokemon.skills.map((skill) => ({
|
||||||
skillId: String(skill.id),
|
skillId: String(skill.id),
|
||||||
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
|
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
|
||||||
}))
|
})),
|
||||||
|
imagePath: pokemon.image?.path ?? ''
|
||||||
};
|
};
|
||||||
|
currentPokemonImage.value = pokemon.image;
|
||||||
|
imageOptions.value = pokemon.image ? [pokemon.image] : [];
|
||||||
syncSkillItemDrops();
|
syncSkillItemDrops();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -327,6 +351,14 @@ function closeFetchOptions() {
|
|||||||
cancelFetchOptionsRequest();
|
cancelFetchOptionsRequest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleFetchIdentifierInput() {
|
||||||
|
fetchOptionsOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFetchOptionsAfterBlur() {
|
||||||
|
window.setTimeout(closeFetchOptions, 120);
|
||||||
|
}
|
||||||
|
|
||||||
async function selectFetchOption(option: PokemonFetchOption) {
|
async function selectFetchOption(option: PokemonFetchOption) {
|
||||||
fetchIdentifier.value = option.identifier;
|
fetchIdentifier.value = option.identifier;
|
||||||
closeFetchOptions();
|
closeFetchOptions();
|
||||||
@@ -361,6 +393,65 @@ function fetchPokemonFromInput() {
|
|||||||
void fetchPokemonByIdentifier();
|
void fetchPokemonByIdentifier();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pokemonImageLabel(image: PokemonImage) {
|
||||||
|
return `${image.version} - ${image.variant}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pokemonImageAlt(image: PokemonImage) {
|
||||||
|
const name = pokemonForm.value.name.trim() || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title'));
|
||||||
|
return t('pages.pokemon.imageAlt', { name, variant: image.variant });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPokemonImage(image: PokemonImage) {
|
||||||
|
pokemonForm.value.imagePath = image.path;
|
||||||
|
currentPokemonImage.value = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPokemonImage() {
|
||||||
|
pokemonForm.value.imagePath = '';
|
||||||
|
currentPokemonImage.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPokemonImages() {
|
||||||
|
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
|
||||||
|
if (!identifier) {
|
||||||
|
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imageBusy.value = true;
|
||||||
|
message.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api.fetchPokemonImageOptions(identifier);
|
||||||
|
const currentId = pokemonIdForSave();
|
||||||
|
if (Number.isInteger(currentId) && currentId > 0 && result.id !== currentId) {
|
||||||
|
message.value = t('pages.pokemon.fetchIdMismatch', { id: result.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchIdentifier.value = result.identifier;
|
||||||
|
imageOptions.value = result.images;
|
||||||
|
|
||||||
|
if (!result.images.length) {
|
||||||
|
message.value = t('pages.pokemon.imageNoMatches');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.images.some((image) => image.path === pokemonForm.value.imagePath)) {
|
||||||
|
selectPokemonImage(result.images[0]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error, t('pages.pokemon.imageFetchFailed'));
|
||||||
|
} finally {
|
||||||
|
imageBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchPokemonImagesFromInput() {
|
||||||
|
void fetchPokemonImages();
|
||||||
|
}
|
||||||
|
|
||||||
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
|
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
|
||||||
const cleanName = name.trim();
|
const cleanName = name.trim();
|
||||||
if (!cleanName) return;
|
if (!cleanName) return;
|
||||||
@@ -399,7 +490,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function savePokemon() {
|
async function savePokemon() {
|
||||||
if (fetchBusy.value) {
|
if (fetchBusy.value || imageBusy.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +518,8 @@ async function savePokemon() {
|
|||||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||||
skillItemDrops: selectedSkillDropRows.value
|
skillItemDrops: selectedSkillDropRows.value
|
||||||
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
|
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
|
||||||
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0)
|
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0),
|
||||||
|
imagePath: pokemonForm.value.imagePath
|
||||||
};
|
};
|
||||||
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
||||||
await router.push(`/pokemon/${saved.id}`);
|
await router.push(`/pokemon/${saved.id}`);
|
||||||
@@ -467,7 +559,9 @@ watch(fetchIdentifier, refreshFetchOptions);
|
|||||||
role="combobox"
|
role="combobox"
|
||||||
:aria-expanded="fetchOptionsOpen"
|
:aria-expanded="fetchOptionsOpen"
|
||||||
aria-controls="pokemon-fetch-results"
|
aria-controls="pokemon-fetch-results"
|
||||||
@focus="openFetchOptions"
|
@click="openFetchOptions"
|
||||||
|
@input="handleFetchIdentifierInput"
|
||||||
|
@blur="closeFetchOptionsAfterBlur"
|
||||||
@keydown.escape.stop="closeFetchOptions"
|
@keydown.escape.stop="closeFetchOptions"
|
||||||
@keydown.enter.prevent="fetchPokemonFromInput"
|
@keydown.enter.prevent="fetchPokemonFromInput"
|
||||||
/>
|
/>
|
||||||
@@ -490,10 +584,16 @@ watch(fetchIdentifier, refreshFetchOptions);
|
|||||||
<p v-else class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchNoMatches') }}</p>
|
<p v-else class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchNoMatches') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pokemon-fetch-panel__actions">
|
||||||
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || fetchBusy" @click="fetchPokemonFromInput">
|
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || fetchBusy" @click="fetchPokemonFromInput">
|
||||||
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||||
{{ fetchBusy ? t('pages.pokemon.fetchingData') : t('pages.pokemon.fetchData') }}
|
{{ fetchBusy ? t('pages.pokemon.fetchingData') : t('pages.pokemon.fetchData') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || imageBusy" @click="fetchPokemonImagesFromInput">
|
||||||
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ imageBusy ? t('pages.pokemon.fetchingImages') : t('pages.pokemon.fetchImages') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
|
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
|
||||||
@@ -575,6 +675,47 @@ watch(fetchIdentifier, refreshFetchOptions);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="imageBusy" class="pokemon-image-preview pokemon-image-preview--loading" aria-busy="true" :aria-label="t('pages.pokemon.loadingImages')">
|
||||||
|
<Skeleton variant="box" height="220px" />
|
||||||
|
<Skeleton width="44%" />
|
||||||
|
<Skeleton width="70%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="selectedPokemonImage" class="pokemon-image-picker">
|
||||||
|
<div class="pokemon-image-preview" :aria-label="t('pages.pokemon.selectedImage')">
|
||||||
|
<div class="pokemon-image-preview__screen">
|
||||||
|
<img :src="selectedPokemonImage.url" :alt="pokemonImageAlt(selectedPokemonImage)" />
|
||||||
|
</div>
|
||||||
|
<div class="pokemon-image-preview__caption">
|
||||||
|
<strong>{{ pokemonImageLabel(selectedPokemonImage) }}</strong>
|
||||||
|
<span>{{ selectedPokemonImage.style }}</span>
|
||||||
|
<p>{{ selectedPokemonImage.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="displayedImageOptions.length" class="pokemon-image-thumbnails" :aria-label="t('pages.pokemon.imageOptions')">
|
||||||
|
<button
|
||||||
|
v-for="image in displayedImageOptions"
|
||||||
|
:key="image.path"
|
||||||
|
type="button"
|
||||||
|
class="pokemon-image-thumbnail"
|
||||||
|
:class="{ active: image.path === pokemonForm.imagePath }"
|
||||||
|
:aria-pressed="image.path === pokemonForm.imagePath"
|
||||||
|
@click="selectPokemonImage(image)"
|
||||||
|
>
|
||||||
|
<img :src="image.url" :alt="pokemonImageAlt(image)" loading="lazy" />
|
||||||
|
<span>{{ image.variant }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="plain-button pokemon-image-clear" @click="clearPokemonImage">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('pages.pokemon.clearImage') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else class="meta-line">{{ t('pages.pokemon.imageEmpty') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
||||||
@@ -681,7 +822,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<template v-if="!loading && options" #footer>
|
<template v-if="!loading && options" #footer>
|
||||||
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy || fetchBusy">
|
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy || fetchBusy || imageBusy">
|
||||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
{{ busy ? t('common.saving') : t('common.save') }}
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { Icon } from '@iconify/vue';
|
|||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import EditMeta from '../components/EditMeta.vue';
|
|
||||||
import EntityChips from '../components/EntityChips.vue';
|
|
||||||
import EntityCard from '../components/EntityCard.vue';
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
import FilterPanel from '../components/FilterPanel.vue';
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
import PageHeader from '../components/PageHeader.vue';
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
@@ -44,8 +42,8 @@ async function loadPokemon() {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pokemonTypeIconSrc(typeId: number): string | null {
|
function pokemonCardImage(item: Pokemon) {
|
||||||
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
return item.image ? { src: item.image.url, alt: t('pages.pokemon.imageAlt', { name: item.name, variant: item.image.variant }) } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -124,40 +122,22 @@ watch(query, loadPokemon);
|
|||||||
</div>
|
</div>
|
||||||
</FilterPanel>
|
</FilterPanel>
|
||||||
|
|
||||||
<div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.pokemon.loadingList')">
|
<div v-if="loading" class="entity-grid pokemon-list-grid" aria-busy="true" :aria-label="t('pages.pokemon.loadingList')">
|
||||||
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
|
||||||
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
|
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||||
<div class="entity-card__content">
|
<div class="entity-card__content">
|
||||||
<Skeleton width="76%" height="24px" />
|
<Skeleton width="128px" height="24px" />
|
||||||
<Skeleton width="58%" />
|
|
||||||
<Skeleton width="68%" />
|
|
||||||
<div class="skeleton-chip-row">
|
|
||||||
<Skeleton v-for="chipIndex in 2" :key="`skills-${chipIndex}`" width="64px" class="skeleton-chip" />
|
|
||||||
</div>
|
|
||||||
<div class="skeleton-chip-row">
|
|
||||||
<Skeleton v-for="chipIndex in 3" :key="`things-${chipIndex}`" width="72px" class="skeleton-chip" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="entity-grid">
|
<div v-else class="entity-grid pokemon-list-grid">
|
||||||
<EntityCard
|
<EntityCard
|
||||||
v-for="item in pokemon"
|
v-for="item in pokemon"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="`#${item.id} ${item.name}`"
|
:title="`#${item.id} ${item.name}`"
|
||||||
:subtitle="t('pages.pokemon.environmentPrefix', { name: item.environment.name })"
|
|
||||||
:to="`/pokemon/${item.id}`"
|
:to="`/pokemon/${item.id}`"
|
||||||
>
|
:image="pokemonCardImage(item)"
|
||||||
<EditMeta :entity="item" />
|
/>
|
||||||
<div v-if="item.types.length" class="chips">
|
|
||||||
<span v-for="type in item.types" :key="type.id" class="chip pokemon-type-chip">
|
|
||||||
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
|
|
||||||
<span>{{ type.name }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<EntityChips :items="item.skills" />
|
|
||||||
<EntityChips :items="item.favorite_things" />
|
|
||||||
</EntityCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PokemonEdit v-if="showEditor" />
|
<PokemonEdit v-if="showEditor" />
|
||||||
|
|||||||
98
frontend/src/views/ResetPasswordView.vue
Normal file
98
frontend/src/views/ResetPasswordView.vue
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import { iconKey, iconLogin } from '../icons';
|
||||||
|
import { api } from '../services/api';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const route = useRoute();
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
|
||||||
|
|
||||||
|
async function submitPasswordReset() {
|
||||||
|
message.value = '';
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
if (!token.value) {
|
||||||
|
errorMessage.value = t('auth.invalidPasswordReset');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
errorMessage.value = t('auth.passwordMismatch');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.resetPassword({ token: token.value, password: password.value });
|
||||||
|
message.value = response.message;
|
||||||
|
password.value = '';
|
||||||
|
confirmPassword.value = '';
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.resetFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="auth-page">
|
||||||
|
<div class="auth-panel">
|
||||||
|
<PageHeader :title="t('auth.resetTitle')" :subtitle="t('auth.resetSubtitle')">
|
||||||
|
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<form v-if="!message" class="auth-form" @submit.prevent="submitPasswordReset">
|
||||||
|
<div class="field">
|
||||||
|
<label for="reset-password">{{ t('auth.newPassword') }}</label>
|
||||||
|
<input
|
||||||
|
id="reset-password"
|
||||||
|
v-model="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="reset-password-confirm">{{ t('auth.confirmPassword') }}</label>
|
||||||
|
<input
|
||||||
|
id="reset-password-confirm"
|
||||||
|
v-model="confirmPassword"
|
||||||
|
autocomplete="new-password"
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
|
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||||
|
<Icon :icon="iconKey" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('auth.resetting') : t('auth.resetPassword') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<StatusMessage v-else variant="success">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
|
<RouterLink v-if="message" class="ui-button ui-button--ghost" to="/login">
|
||||||
|
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('auth.goLogin') }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
160
frontend/src/views/UserProfileView.vue
Normal file
160
frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import { iconProfile, iconSave } from '../icons';
|
||||||
|
import { api, notifyAuthChange, type AuthUser } from '../services/api';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const user = ref<AuthUser | null>(null);
|
||||||
|
const displayName = ref('');
|
||||||
|
const loading = ref(true);
|
||||||
|
const busy = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const errorMessage = ref('');
|
||||||
|
|
||||||
|
const trimmedDisplayName = computed(() => displayName.value.trim());
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
const currentUser = user.value;
|
||||||
|
if (!currentUser) return false;
|
||||||
|
return trimmedDisplayName.value !== currentUser.displayName;
|
||||||
|
});
|
||||||
|
const profileInitial = computed(() => {
|
||||||
|
const name = user.value?.displayName.trim() || user.value?.email.trim() || '';
|
||||||
|
return name.charAt(0).toUpperCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
loading.value = true;
|
||||||
|
message.value = '';
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.me();
|
||||||
|
user.value = response.user;
|
||||||
|
displayName.value = response.user.displayName;
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfile() {
|
||||||
|
message.value = '';
|
||||||
|
errorMessage.value = '';
|
||||||
|
|
||||||
|
if (!trimmedDisplayName.value) {
|
||||||
|
errorMessage.value = t('pages.profile.displayNameRequired');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const response = await api.updateMe({ displayName: trimmedDisplayName.value });
|
||||||
|
user.value = response.user;
|
||||||
|
displayName.value = response.user.displayName;
|
||||||
|
message.value = t('pages.profile.saved');
|
||||||
|
notifyAuthChange();
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.saveFailed');
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadProfile();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="profile-page">
|
||||||
|
<PageHeader :title="t('pages.profile.title')" :subtitle="t('pages.profile.subtitle')">
|
||||||
|
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div v-if="loading" class="profile-layout" aria-busy="true" :aria-label="t('pages.profile.loading')">
|
||||||
|
<section class="profile-card profile-card--identity" aria-hidden="true">
|
||||||
|
<div class="profile-identity">
|
||||||
|
<Skeleton variant="box" width="58px" height="58px" />
|
||||||
|
<div class="profile-identity__copy">
|
||||||
|
<Skeleton width="160px" height="28px" />
|
||||||
|
<Skeleton width="220px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="profile-card" aria-hidden="true">
|
||||||
|
<Skeleton width="180px" height="28px" />
|
||||||
|
<div class="auth-form">
|
||||||
|
<div class="field">
|
||||||
|
<Skeleton width="110px" />
|
||||||
|
<Skeleton variant="box" height="44px" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<Skeleton width="70px" />
|
||||||
|
<Skeleton variant="box" height="44px" />
|
||||||
|
</div>
|
||||||
|
<Skeleton variant="box" width="120px" height="42px" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="user" class="profile-layout">
|
||||||
|
<section class="profile-card profile-card--identity" :aria-label="t('pages.profile.accountSummary')">
|
||||||
|
<div class="profile-identity">
|
||||||
|
<div class="profile-avatar" aria-hidden="true">{{ profileInitial }}</div>
|
||||||
|
<div class="profile-identity__copy">
|
||||||
|
<h2>{{ user.displayName }}</h2>
|
||||||
|
<p>{{ user.email }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge
|
||||||
|
:label="user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified')"
|
||||||
|
:tone="user.emailVerified ? 'success' : 'warning'"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="profile-card" :aria-label="t('pages.profile.profileDetails')">
|
||||||
|
<div class="profile-card__header">
|
||||||
|
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
||||||
|
<h2>{{ t('pages.profile.profileDetails') }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="auth-form" @submit.prevent="saveProfile">
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-display-name">{{ t('auth.displayName') }}</label>
|
||||||
|
<input
|
||||||
|
id="profile-display-name"
|
||||||
|
v-model="displayName"
|
||||||
|
autocomplete="nickname"
|
||||||
|
maxlength="40"
|
||||||
|
required
|
||||||
|
:disabled="busy"
|
||||||
|
/>
|
||||||
|
<small class="profile-field-note">{{ t('pages.profile.displayNameHint') }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile-email">{{ t('auth.email') }}</label>
|
||||||
|
<input id="profile-email" class="profile-readonly-input" :value="user.email" readonly type="email" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||||
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
|
<button class="ui-button ui-button--primary" :disabled="busy || !hasChanges" type="submit">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -4,5 +4,5 @@
|
|||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["vite/client", "vitest/globals"]
|
"types": ["vite/client", "vitest/globals"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../system-wordings.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "pokopia",
|
"name": "pokopia",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.33.2",
|
"packageManager": "pnpm@10.33.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev",
|
"dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev",
|
||||||
|
|||||||
1344
system-wordings.ts
Normal file
1344
system-wordings.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user