Compare commits

...

7 Commits

Author SHA1 Message Date
36e10a06b0 feat(auth): add user profile page and display name update
Add PATCH /api/auth/me endpoint to update user display name
Create UserProfileView for managing account details and email status
Update AppShell sidebar to link authenticated user to profile page
2026-05-02 22:38:33 +08:00
4a42756e2e feat(auth): add password reset and remember me options
Add password reset request and reset endpoints with email verification
Add "Remember me" option to login for persistent sessions
Create frontend views for forgot and reset password flows
2026-05-02 22:13:10 +08:00
97f06794a8 refactor(pokemon): simplify list card to display only image and name
Remove environment, types, skills, and meta from the list view
Update CSS and skeleton loaders for a centered, larger image layout
2026-05-02 21:37:09 +08:00
874ecc5625 feat(pokemon): redesign image display with side thumbnail and modal
Move image thumbnail to the right of base stats
Display detailed image information in a modal
2026-05-02 21:15:48 +08:00
cf0ae566c0 feat(pokemon): add image selection and display from pokesprite
Add image metadata fields to Pokemon schema and API
Implement image candidate fetching from pokesprite static tree
Add Pokédex-style image picker to edit form and display in details
2026-05-02 20:59:33 +08:00
475e3577dd refactor(admin): redesign system wording layout with sidebar and tabs
Replace module and surface dropdowns with a sidebar and tabbed interface.
Improve navigation and usability in the admin wording section.
2026-05-02 11:57:59 +08:00
976a2a2482 feat(i18n): implement dynamic system wording management
Add database schema and API endpoints for system wording keys and values
Replace hardcoded translations in frontend and backend with dynamic messages
Add System Wordings management interface to Admin view
2026-05-02 11:48:11 +08:00
31 changed files with 3946 additions and 1164 deletions

View File

@@ -3,4 +3,3 @@
**/dist **/dist
**/*.log **/*.log
**/.env **/.env
frontend

View File

@@ -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、物品、材料单、栖息地的列表排序。
## 开发与验证 ## 开发与验证

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
] ]

View File

@@ -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);

View 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 });
}

View File

@@ -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"]
} }

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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);
} }

View File

@@ -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') }}

View File

@@ -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',

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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';

View File

@@ -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 } };

View File

@@ -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),

View File

@@ -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%;
} }

View File

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

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

View File

@@ -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">

View File

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

View File

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

View File

@@ -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" />

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

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

View File

@@ -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"]
} }

View File

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

File diff suppressed because it is too large Load Diff