Compare commits
7 Commits
e8e20539c9
...
36e10a06b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 36e10a06b0 | |||
| 4a42756e2e | |||
| 97f06794a8 | |||
| 874ecc5625 | |||
| cf0ae566c0 | |||
| 475e3577dd | |||
| 976a2a2482 |
@@ -3,4 +3,3 @@
|
||||
**/dist
|
||||
**/*.log
|
||||
**/.env
|
||||
frontend
|
||||
|
||||
58
DESIGN.md
58
DESIGN.md
@@ -6,7 +6,7 @@
|
||||
- 所有人都可以浏览 Wiki 内容。
|
||||
- 已注册并完成邮箱验证的用户可以创建、编辑、删除 Wiki 内容。
|
||||
- 前台以 Pokemon、栖息地、物品、材料单、每日 CheckList、Life、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||
- 管理入口用于维护全局配置、语言、列表排序和每日 CheckList。
|
||||
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||
|
||||
## 技术栈
|
||||
|
||||
@@ -68,6 +68,23 @@
|
||||
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
||||
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
||||
- 编辑表单必须避免本地化 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,并带过期时间和使用状态。
|
||||
- 密码重置成功后不自动登录,并删除该用户已有 session。
|
||||
- 登录页提供 Remember me:
|
||||
- 未勾选时前端将登录 token 保存在 `sessionStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 1 天。
|
||||
- 勾选时前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`,服务端 session 有效期为 30 天。
|
||||
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
||||
- 前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`。
|
||||
- 用户可退出登录,退出时删除对应 session。
|
||||
- 对外用户字段只包含必要信息:
|
||||
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||
- 编辑署名:`id`、`displayName`
|
||||
- User Profile:
|
||||
- 登录用户可通过 `/profile` 查看自己的邮箱、邮箱验证状态和显示名。
|
||||
- 当前版本只允许用户更新自己的 `displayName`,不支持头像、公开个人主页、邮箱修改或直接密码修改。
|
||||
- 更新显示名后,API 仍只返回当前用户必要字段,不返回 session、token/hash、内部审计或调试数据。
|
||||
- 显示名用于编辑署名、讨论和 Life 内容作者展示。
|
||||
|
||||
## Community 编辑与审计
|
||||
|
||||
@@ -215,19 +244,28 @@ Pokemon 可配置:
|
||||
Pokemon 编辑表单使用标签页组织字段:
|
||||
|
||||
- 编辑表单提供 Fetch data 功能:
|
||||
- 已验证用户可输入 data identifier 或 Pokemon ID,从仓库 `data/` CSV 查询基础资料并填入当前表单。
|
||||
- 已验证用户可输入 data identifier 或 Pokemon ID,从同一个搜索输入查询基础资料或图片候选。
|
||||
- Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。
|
||||
- Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。
|
||||
- Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。
|
||||
- Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。
|
||||
- Fetch 只填入 CSV 可提供的字段:ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。
|
||||
- Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。
|
||||
- Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。
|
||||
- Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。
|
||||
- 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、名称
|
||||
- 第二行:喜欢的环境、特长
|
||||
- 第三行:喜欢的东西
|
||||
- 特长掉落物品随已选择且支持掉落物的特长显示
|
||||
- Pokemon 图片选择区
|
||||
- Advance 标签页:
|
||||
- 第一行:Genus
|
||||
- 第二行:Details
|
||||
@@ -246,14 +284,17 @@ Pokemon 列表功能:
|
||||
- 满足任意条件
|
||||
- 满足全部条件
|
||||
- 按自定义排序展示
|
||||
- Pokemon 列表卡片只展示 Pokemon 图片和下方的 `#ID 名称`;不展示喜欢的环境、属性、特长、喜欢的东西或编辑元信息。
|
||||
- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。
|
||||
|
||||
Pokemon 详情页展示:
|
||||
|
||||
- 基本信息
|
||||
- 已配置图片时,详情主内容在六维 Stats 右侧展示正方形居中的 Pokédex 风格图片;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情;未配置图片时不显示图片区。
|
||||
- 主内容顶部按以下布局展示:
|
||||
- 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容
|
||||
- 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
|
||||
- 右侧:六维 Stats
|
||||
- 右侧:六维 Stats;已配置图片时图片展示在 Stats 右侧
|
||||
- 六维使用 ProgressBar 展示,最大值按 150 计算。
|
||||
- 特长
|
||||
- 特长掉落物品
|
||||
@@ -462,6 +503,7 @@ API 暴露边界:
|
||||
- UI 风格以 `DesignGuidelines.html` 为准。
|
||||
- 页面结构以 `AppShell`、`PageHeader`、列表、详情区和管理区为核心。
|
||||
- 全局主导航使用 `AppShell` 侧边栏;移动端通过导航按钮打开侧边栏抽屉。
|
||||
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||
- 页面级分类、筛选或辅助内容切换使用 Tabs,避免在内容页继续增加侧边栏。
|
||||
- 导航和主要操作使用图标增强识别。
|
||||
- 数据加载状态使用 Skeleton,避免裸文本 loading。
|
||||
@@ -486,6 +528,7 @@ API 暴露边界:
|
||||
公开浏览 API:
|
||||
|
||||
- `GET /api/languages`
|
||||
- `GET /api/system-wordings`
|
||||
- `GET /api/options`
|
||||
- `GET /api/daily-checklist`
|
||||
- `GET /api/pokemon`
|
||||
@@ -504,7 +547,10 @@ API 暴露边界:
|
||||
- `POST /api/auth/register`
|
||||
- `POST /api/auth/verify-email`
|
||||
- `POST /api/auth/login`
|
||||
- `POST /api/auth/request-password-reset`
|
||||
- `POST /api/auth/reset-password`
|
||||
- `GET /api/auth/me`
|
||||
- `PATCH /api/auth/me`:更新当前用户显示名;需要登录;只接收并返回当前用户必要字段。
|
||||
- `POST /api/auth/logout`
|
||||
|
||||
已验证用户编辑 API:
|
||||
@@ -512,6 +558,7 @@ API 暴露边界:
|
||||
- Pokemon、栖息地、物品、材料单的创建、更新、删除。
|
||||
- `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/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要已验证用户;只返回 `id`、`identifier` 和图片候选列表。
|
||||
- Life Post 的创建,以及作者本人对 Life Post 的更新、删除。
|
||||
- `POST /api/life-posts`
|
||||
- `PUT /api/life-posts/:id`
|
||||
@@ -530,6 +577,9 @@ API 暴露边界:
|
||||
- 每日 CheckList 的创建、更新、删除、排序。
|
||||
- 全局配置项的创建、更新、删除、排序。
|
||||
- 语言的创建、更新、删除、排序。
|
||||
- 系统级文案的查看和更新。
|
||||
- `GET /api/admin/system-wordings`
|
||||
- `PUT /api/admin/system-wordings/:key`
|
||||
- Pokemon、物品、材料单、栖息地的列表排序。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json ./
|
||||
RUN corepack enable && pnpm install
|
||||
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
|
||||
CMD ["pnpm", "run", "start"]
|
||||
|
||||
@@ -85,6 +85,39 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
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
|
||||
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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
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_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 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)
|
||||
);
|
||||
|
||||
@@ -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_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 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 updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
@@ -2,11 +2,14 @@ import { createHash, randomBytes, scrypt as scryptCallback, timingSafeEqual } fr
|
||||
import { promisify } from 'node:util';
|
||||
import type { PoolClient, QueryResultRow } from 'pg';
|
||||
import { pool, queryOne } from './db.ts';
|
||||
import { systemMessage } from './systemWordingQueries.ts';
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
const passwordKeyLength = 64;
|
||||
const verificationTokenHours = 24;
|
||||
const sessionDays = 30;
|
||||
const passwordResetTokenHours = 1;
|
||||
const rememberedSessionDays = 30;
|
||||
const sessionOnlySessionDays = 1;
|
||||
const defaultLocale = 'en';
|
||||
|
||||
type DbClient = PoolClient;
|
||||
@@ -34,11 +37,19 @@ type AuthMessageKey =
|
||||
| 'emailAlreadyRegistered'
|
||||
| 'checkVerificationEmail'
|
||||
| 'emailVerified'
|
||||
| 'checkPasswordResetEmail'
|
||||
| 'passwordResetComplete'
|
||||
| 'invalidCredentials'
|
||||
| 'verifyEmailFirst'
|
||||
| 'invalidResetToken'
|
||||
| 'emailSubject'
|
||||
| 'emailHtml'
|
||||
| 'emailText';
|
||||
| 'emailText'
|
||||
| 'passwordResetSubject'
|
||||
| 'passwordResetHtml'
|
||||
| 'passwordResetText';
|
||||
|
||||
type AuthTokenMessageKey = 'invalidToken' | 'invalidResetToken';
|
||||
|
||||
export type AuthUser = {
|
||||
id: number;
|
||||
@@ -53,87 +64,74 @@ function statusError(message: string, statusCode: number): StatusError {
|
||||
return error;
|
||||
}
|
||||
|
||||
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): string {
|
||||
const messages: Record<string, Record<AuthMessageKey, string>> = {
|
||||
en: {
|
||||
emailRequired: 'Email is required',
|
||||
invalidEmail: 'Email format is invalid',
|
||||
displayNameRequired: 'Display name is required',
|
||||
displayNameLength: 'Display name must be 1 to 40 characters',
|
||||
passwordLength: 'Password must be at least 8 characters',
|
||||
invalidToken: 'The verification link is invalid or expired',
|
||||
emailAlreadyRegistered: 'This email is already registered',
|
||||
checkVerificationEmail: 'Please check your verification email',
|
||||
emailVerified: 'Email verified',
|
||||
invalidCredentials: 'Email or password is incorrect',
|
||||
verifyEmailFirst: 'Please complete email verification first',
|
||||
emailSubject: 'Verify your Pokopia Wiki email',
|
||||
emailHtml:
|
||||
'<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>',
|
||||
emailText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.'
|
||||
},
|
||||
'zh-CN': {
|
||||
emailRequired: '请输入邮箱',
|
||||
invalidEmail: '邮箱格式不正确',
|
||||
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} 小时后失效。'
|
||||
}
|
||||
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): Promise<string> {
|
||||
const messageKeys: Record<AuthMessageKey, string> = {
|
||||
emailRequired: 'server.auth.emailRequired',
|
||||
invalidEmail: 'server.auth.invalidEmail',
|
||||
displayNameRequired: 'server.auth.displayNameRequired',
|
||||
displayNameLength: 'server.auth.displayNameLength',
|
||||
passwordLength: 'server.auth.passwordLength',
|
||||
invalidToken: 'server.auth.invalidToken',
|
||||
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
|
||||
checkVerificationEmail: 'server.auth.checkVerificationEmail',
|
||||
emailVerified: 'server.auth.emailVerified',
|
||||
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
|
||||
passwordResetComplete: 'server.auth.passwordResetComplete',
|
||||
invalidCredentials: 'server.auth.invalidCredentials',
|
||||
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
||||
invalidResetToken: 'server.auth.invalidResetToken',
|
||||
emailSubject: 'email.auth.verificationSubject',
|
||||
emailHtml: 'email.auth.verificationHtml',
|
||||
emailText: 'email.auth.verificationText',
|
||||
passwordResetSubject: 'email.auth.passwordResetSubject',
|
||||
passwordResetHtml: 'email.auth.passwordResetHtml',
|
||||
passwordResetText: 'email.auth.passwordResetText'
|
||||
};
|
||||
|
||||
let message = messages[locale]?.[key] ?? messages[defaultLocale][key];
|
||||
for (const [paramKey, paramValue] of Object.entries(params)) {
|
||||
message = message.replaceAll(`{${paramKey}}`, String(paramValue));
|
||||
}
|
||||
return message;
|
||||
return systemMessage(locale || defaultLocale, messageKeys[key], params);
|
||||
}
|
||||
|
||||
function cleanEmail(value: unknown, locale: string): string {
|
||||
async function cleanEmail(value: unknown, locale: string): Promise<string> {
|
||||
if (typeof value !== 'string') {
|
||||
throw statusError(authMessage(locale, 'emailRequired'), 400);
|
||||
throw statusError(await authMessage(locale, 'emailRequired'), 400);
|
||||
}
|
||||
|
||||
const email = value.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw statusError(authMessage(locale, 'invalidEmail'), 400);
|
||||
throw statusError(await authMessage(locale, 'invalidEmail'), 400);
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
function cleanDisplayName(value: unknown, locale: string): string {
|
||||
async function cleanDisplayName(value: unknown, locale: string): Promise<string> {
|
||||
if (typeof value !== 'string') {
|
||||
throw statusError(authMessage(locale, 'displayNameRequired'), 400);
|
||||
throw statusError(await authMessage(locale, 'displayNameRequired'), 400);
|
||||
}
|
||||
|
||||
const displayName = value.trim();
|
||||
if (displayName.length < 1 || displayName.length > 40) {
|
||||
throw statusError(authMessage(locale, 'displayNameLength'), 400);
|
||||
throw statusError(await authMessage(locale, 'displayNameLength'), 400);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw statusError(authMessage(locale, 'passwordLength'), 400);
|
||||
throw statusError(await authMessage(locale, 'passwordLength'), 400);
|
||||
}
|
||||
|
||||
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) {
|
||||
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
||||
throw statusError(await authMessage(locale, messageKey), 400);
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
@@ -209,16 +207,27 @@ function getEmailConfig() {
|
||||
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 url = new URL('/verify-email', origin);
|
||||
const url = new URL(pathname, origin);
|
||||
url.searchParams.set('token', token);
|
||||
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> {
|
||||
const { apiKey, from } = getEmailConfig();
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -228,9 +237,36 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: [email],
|
||||
subject: authMessage(locale, 'emailSubject'),
|
||||
html: authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }),
|
||||
text: authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours })
|
||||
subject,
|
||||
html,
|
||||
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) {
|
||||
const email = cleanEmail(payload.email, locale);
|
||||
const displayName = cleanDisplayName(payload.displayName, locale);
|
||||
const password = cleanPassword(payload.password, locale);
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||
const password = await cleanPassword(payload.password, locale);
|
||||
const passwordHash = await hashPassword(password);
|
||||
const verificationToken = createPlainToken();
|
||||
const verificationTokenHash = hashToken(verificationToken);
|
||||
@@ -256,7 +292,7 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
||||
);
|
||||
|
||||
if (existingUser?.email_verified_at) {
|
||||
throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409);
|
||||
throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409);
|
||||
}
|
||||
|
||||
const user = existingUser
|
||||
@@ -295,11 +331,11 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
||||
});
|
||||
|
||||
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) {
|
||||
const token = cleanToken(payload.token, locale);
|
||||
const token = await cleanToken(payload.token, locale);
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
@@ -317,7 +353,7 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
||||
);
|
||||
|
||||
if (!tokenRow) {
|
||||
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
||||
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
||||
}
|
||||
|
||||
const user = await clientQueryOne<UserRow>(
|
||||
@@ -332,31 +368,112 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
||||
);
|
||||
|
||||
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', [
|
||||
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) {
|
||||
const email = cleanEmail(payload.email, locale);
|
||||
const password = cleanPassword(payload.password, locale);
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
const password = await cleanPassword(payload.password, locale);
|
||||
const sessionDays = payload.rememberMe === true ? rememberedSessionDays : sessionOnlySessionDays;
|
||||
const user = await queryOne<LoginUserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
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) {
|
||||
throw statusError(authMessage(locale, 'verifyEmailFirst'), 403);
|
||||
throw statusError(await authMessage(locale, 'verifyEmailFirst'), 403);
|
||||
}
|
||||
|
||||
const sessionToken = createPlainToken();
|
||||
@@ -390,6 +507,29 @@ export async function getUserBySessionToken(token: string): Promise<AuthUser | n
|
||||
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> {
|
||||
if (token.length < 32) {
|
||||
return;
|
||||
|
||||
@@ -70,6 +70,23 @@ type PokemonStats = {
|
||||
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 = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -84,6 +101,7 @@ type PokemonPayload = {
|
||||
skillIds: number[];
|
||||
favoriteThingIds: number[];
|
||||
skillItemDrops: SkillItemDrop[];
|
||||
image: PokemonImage | null;
|
||||
};
|
||||
|
||||
type PokemonFetchResult = {
|
||||
@@ -255,6 +273,7 @@ type PokemonChangeSource = {
|
||||
details: string;
|
||||
heightInches: number;
|
||||
weightPounds: number;
|
||||
image: PokemonImage | null;
|
||||
types: Array<{ name: string }>;
|
||||
stats: PokemonStats;
|
||||
environment: { name: string };
|
||||
@@ -289,6 +308,8 @@ const defaultLifePostLimit = 20;
|
||||
const maxLifePostLimit = 50;
|
||||
const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const;
|
||||
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 }> = [
|
||||
{ key: 'hp', label: 'HP' },
|
||||
{ key: 'attack', label: 'Attack' },
|
||||
@@ -1013,6 +1034,231 @@ function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: str
|
||||
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 {
|
||||
if (!value) {
|
||||
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 {
|
||||
const id = csvInteger(row, 'id');
|
||||
const identifier = csvText(row, 'identifier');
|
||||
@@ -1361,6 +1630,7 @@ async function pokemonEditChanges(
|
||||
pushChange(changes, 'Details', before.details, after.details);
|
||||
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
|
||||
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, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
|
||||
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",
|
||||
p.weight_pounds AS "weightPounds",
|
||||
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(
|
||||
'hp', p.hp,
|
||||
'attack', p.attack,
|
||||
@@ -2847,8 +3125,10 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||
}
|
||||
}
|
||||
|
||||
const id = requirePositiveInteger(payload.id, 'Pokemon ID is required');
|
||||
|
||||
return {
|
||||
id: requirePositiveInteger(payload.id, 'Pokemon ID is required'),
|
||||
id,
|
||||
name: cleanName(payload.name, 'Pokemon name is required'),
|
||||
genus: cleanOptionalText(payload.genus),
|
||||
details: cleanOptionalText(payload.details),
|
||||
@@ -2860,7 +3140,8 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
|
||||
environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'),
|
||||
skillIds,
|
||||
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_defense,
|
||||
speed,
|
||||
image_path,
|
||||
image_style,
|
||||
image_version,
|
||||
image_variant,
|
||||
image_description,
|
||||
sort_order,
|
||||
created_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,
|
||||
@@ -2950,6 +3236,11 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
|
||||
cleanPayload.stats.specialAttack,
|
||||
cleanPayload.stats.specialDefense,
|
||||
cleanPayload.stats.speed,
|
||||
cleanPayload.image?.path ?? '',
|
||||
cleanPayload.image?.style ?? '',
|
||||
cleanPayload.image?.version ?? '',
|
||||
cleanPayload.image?.variant ?? '',
|
||||
cleanPayload.image?.description ?? '',
|
||||
sortOrder,
|
||||
userId
|
||||
]
|
||||
@@ -2983,9 +3274,14 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
||||
special_attack = $10,
|
||||
special_defense = $11,
|
||||
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()
|
||||
WHERE id = $14
|
||||
WHERE id = $19
|
||||
`,
|
||||
[
|
||||
cleanPayload.name,
|
||||
@@ -3000,6 +3296,11 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
|
||||
cleanPayload.stats.specialAttack,
|
||||
cleanPayload.stats.specialDefense,
|
||||
cleanPayload.stats.speed,
|
||||
cleanPayload.image?.path ?? '',
|
||||
cleanPayload.image?.style ?? '',
|
||||
cleanPayload.image?.version ?? '',
|
||||
cleanPayload.image?.variant ?? '',
|
||||
cleanPayload.image?.description ?? '',
|
||||
userId,
|
||||
id
|
||||
]
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import cors from '@fastify/cors';
|
||||
import Fastify 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 {
|
||||
cleanLocale,
|
||||
@@ -29,6 +39,7 @@ import {
|
||||
deletePokemon,
|
||||
deleteRecipe,
|
||||
fetchPokemonData,
|
||||
fetchPokemonImageOptions,
|
||||
getHabitat,
|
||||
getItem,
|
||||
getOptions,
|
||||
@@ -62,6 +73,14 @@ import {
|
||||
updatePokemon,
|
||||
updateRecipe
|
||||
} from './queries.ts';
|
||||
import {
|
||||
getSystemWordings,
|
||||
listSystemWordingRows,
|
||||
localizedStatusMessage,
|
||||
syncSystemWordingCatalog,
|
||||
systemMessage,
|
||||
updateSystemWordingValue
|
||||
} from './systemWordingQueries.ts';
|
||||
|
||||
const app = Fastify({
|
||||
logger: true
|
||||
@@ -78,23 +97,23 @@ app.setErrorHandler(async (error, _request, reply) => {
|
||||
const locale = requestLocale(_request);
|
||||
|
||||
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') {
|
||||
return reply.code(409).send({ message: serverMessage(locale, 'duplicate') });
|
||||
return reply.code(409).send({ message: await serverMessage(locale, 'duplicate') });
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
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 }));
|
||||
@@ -111,27 +130,15 @@ function requestLocale(request: FastifyRequest): string {
|
||||
return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale));
|
||||
}
|
||||
|
||||
function serverMessage(locale: string, key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst'): string {
|
||||
const messages = {
|
||||
en: {
|
||||
foreignKey: 'Referenced data does not exist or the record is currently in use',
|
||||
duplicate: 'A record with the same name or ID already exists',
|
||||
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: '请先完成邮箱验证'
|
||||
function serverMessage(
|
||||
locale: string,
|
||||
key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst' | 'notFound'
|
||||
): Promise<string> {
|
||||
return systemMessage(locale, `server.errors.${key}`);
|
||||
}
|
||||
};
|
||||
|
||||
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> {
|
||||
@@ -140,12 +147,12 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
|
||||
const locale = requestLocale(request);
|
||||
|
||||
if (!user) {
|
||||
reply.code(401).send({ message: serverMessage(locale, 'loginRequired') });
|
||||
reply.code(401).send({ message: await serverMessage(locale, 'loginRequired') });
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
reply.code(403).send({ message: serverMessage(locale, 'verifyEmailFirst') });
|
||||
reply.code(403).send({ message: await serverMessage(locale, 'verifyEmailFirst') });
|
||||
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/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) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
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 };
|
||||
});
|
||||
|
||||
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) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
if (token) {
|
||||
@@ -195,6 +222,8 @@ app.post('/api/auth/logout', async (request, reply) => {
|
||||
|
||||
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/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 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) => {
|
||||
@@ -233,7 +262,7 @@ app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request,
|
||||
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.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 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) => {
|
||||
@@ -253,7 +282,7 @@ app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) => {
|
||||
@@ -263,7 +292,7 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) => {
|
||||
@@ -273,7 +302,7 @@ app.delete('/api/life-posts/:id', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) => {
|
||||
@@ -283,13 +312,13 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) => {
|
||||
const { entityType, entityId } = request.params as { entityType: string; entityId: string };
|
||||
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) => {
|
||||
@@ -305,7 +334,7 @@ app.post('/api/discussions/:entityType/:entityId/comments', async (request, repl
|
||||
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/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>,
|
||||
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) => {
|
||||
@@ -337,7 +366,7 @@ app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
||||
|
||||
const { id } = request.params as { id: string };
|
||||
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) =>
|
||||
@@ -356,7 +385,7 @@ app.get('/api/pokemon/:id', async (request, reply) => {
|
||||
const pokemon = await getPokemon(Number(id), requestLocale(request));
|
||||
|
||||
if (!pokemon) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
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));
|
||||
|
||||
if (!pokemon) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
return pokemon;
|
||||
@@ -396,7 +430,7 @@ app.delete('/api/pokemon/:id', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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)));
|
||||
@@ -406,7 +440,7 @@ app.get('/api/habitats/:id', async (request, reply) => {
|
||||
const habitat = await getHabitat(Number(id), requestLocale(request));
|
||||
|
||||
if (!habitat) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
if (!habitat) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
return habitat;
|
||||
@@ -441,7 +475,7 @@ app.delete('/api/habitats/:id', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) =>
|
||||
@@ -453,7 +487,7 @@ app.get('/api/items/:id', async (request, reply) => {
|
||||
const item = await getItem(Number(id), requestLocale(request));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
if (!item) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
return item;
|
||||
@@ -488,7 +522,7 @@ app.delete('/api/items/:id', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) =>
|
||||
@@ -500,7 +534,7 @@ app.get('/api/recipes/:id', async (request, reply) => {
|
||||
const recipe = await getRecipe(Number(id), requestLocale(request));
|
||||
|
||||
if (!recipe) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
if (!recipe) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
|
||||
return recipe;
|
||||
@@ -535,7 +569,7 @@ app.delete('/api/recipes/:id', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) => {
|
||||
@@ -564,7 +598,7 @@ app.put('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||
user.id,
|
||||
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) => {
|
||||
@@ -574,7 +608,7 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => {
|
||||
}
|
||||
const { id } = request.params as { id: string };
|
||||
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) => {
|
||||
@@ -628,7 +662,21 @@ app.delete('/api/admin/languages/:code', async (request, reply) => {
|
||||
}
|
||||
const { code } = request.params as { code: string };
|
||||
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) => {
|
||||
@@ -638,7 +686,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
|
||||
}
|
||||
const { type } = request.params as { type: string };
|
||||
if (!isConfigType(type)) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, 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 };
|
||||
if (!isConfigType(type)) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
return reply
|
||||
.code(201)
|
||||
@@ -664,7 +712,7 @@ app.put('/api/admin/config/:type/order', async (request, reply) => {
|
||||
}
|
||||
const { type } = request.params as { type: string };
|
||||
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));
|
||||
});
|
||||
@@ -676,10 +724,10 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => {
|
||||
}
|
||||
const { type, id } = request.params as { type: string; id: string };
|
||||
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));
|
||||
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) => {
|
||||
@@ -689,16 +737,17 @@ app.delete('/api/admin/config/:type/:id', async (request, reply) => {
|
||||
}
|
||||
const { type, id } = request.params as { type: string; id: string };
|
||||
if (!isConfigType(type)) {
|
||||
return reply.code(404).send({ message: 'Not found' });
|
||||
return notFound(reply, request);
|
||||
}
|
||||
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);
|
||||
|
||||
try {
|
||||
await initializeDatabase();
|
||||
await syncSystemWordingCatalog();
|
||||
await app.listen({ host: '0.0.0.0', port });
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
|
||||
349
backend/src/systemWordingQueries.ts
Normal file
349
backend/src/systemWordingQueries.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { defaultLocale, systemWordingCatalogEntries, systemWordingFallback, type SystemWordingTree } from '../../system-wordings.ts';
|
||||
import { pool, query } from './db.ts';
|
||||
|
||||
type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||
type SystemWordingValueRow = {
|
||||
key: string;
|
||||
module: string;
|
||||
surface: SystemWordingSurface;
|
||||
description: string;
|
||||
placeholders: unknown;
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
missing: boolean;
|
||||
updatedAt: Date | null;
|
||||
updatedBy: { id: number; displayName: string } | null;
|
||||
};
|
||||
type ValidationError = Error & { statusCode: number };
|
||||
|
||||
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
|
||||
const wordingKeyPattern = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/;
|
||||
const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g;
|
||||
const surfaces = new Set<SystemWordingSurface>(['frontend', 'backend', 'email']);
|
||||
|
||||
const legacyMessageKeys = new Map<string, string>([
|
||||
['Record does not exist', 'server.validation.recordMissing'],
|
||||
['Language code is invalid', 'server.validation.languageCodeInvalid'],
|
||||
['Language name is required', 'server.validation.languageNameRequired'],
|
||||
['Default language must be English', 'server.validation.defaultLanguageMustBeEnglish'],
|
||||
['Default language must be enabled', 'server.validation.defaultLanguageMustBeEnabled'],
|
||||
['Language not found', 'server.validation.languageNotFound'],
|
||||
['A default language is required', 'server.validation.defaultLanguageRequired'],
|
||||
['Default language cannot be deleted', 'server.validation.defaultLanguageCannotBeDeleted'],
|
||||
['Please select a language', 'server.validation.selectLanguage'],
|
||||
['Language does not exist', 'server.validation.languageDoesNotExist'],
|
||||
['Pokemon identifier is required', 'server.validation.pokemonIdentifierRequired'],
|
||||
['Pokemon type data is unavailable', 'server.validation.pokemonTypeDataUnavailable'],
|
||||
['Pokemon data was not found', 'server.validation.pokemonDataNotFound'],
|
||||
['Pokemon image path is invalid', 'server.validation.pokemonImagePathInvalid'],
|
||||
['Please enter a task', 'server.validation.taskRequired'],
|
||||
['Please select a task', 'server.validation.selectTask'],
|
||||
['Task does not exist', 'server.validation.taskDoesNotExist'],
|
||||
['Please enter a post', 'server.validation.postRequired'],
|
||||
['Post is too long', 'server.validation.postTooLong'],
|
||||
['Please enter a comment', 'server.validation.commentRequired'],
|
||||
['Comment is too long', 'server.validation.commentTooLong'],
|
||||
['Reaction is invalid', 'server.validation.reactionInvalid'],
|
||||
['Cursor is invalid', 'server.validation.cursorInvalid'],
|
||||
['Tag is invalid', 'server.validation.tagInvalid'],
|
||||
['Entity type is invalid', 'server.validation.entityTypeInvalid'],
|
||||
['Record is invalid', 'server.validation.recordInvalid'],
|
||||
['Comment is invalid', 'server.validation.commentInvalid'],
|
||||
['Please select a record', 'server.validation.selectRecord'],
|
||||
['Choose at least 1 type', 'server.validation.typeMin'],
|
||||
['Choose at most 2 types', 'server.validation.typeMax'],
|
||||
['Choose at most 2 specialities', 'server.validation.skillMax'],
|
||||
['Choose at most 6 favourites', 'server.validation.favoriteMax'],
|
||||
['Drop items must be linked to selected specialities', 'server.validation.dropItemSelectedSkill'],
|
||||
['Pokemon ID is required', 'server.validation.pokemonIdRequired'],
|
||||
['Pokemon name is required', 'server.validation.pokemonNameRequired'],
|
||||
['Height must be a non-negative number', 'server.validation.heightNonNegative'],
|
||||
['Weight must be a non-negative number', 'server.validation.weightNonNegative'],
|
||||
['Ideal Habitat is required', 'server.validation.environmentRequired'],
|
||||
['This speciality cannot have a drop item', 'server.validation.skillNoDrop'],
|
||||
['Habitat name is required', 'server.validation.habitatNameRequired'],
|
||||
['Usage is required', 'server.validation.usageRequired'],
|
||||
['Item name is required', 'server.validation.itemNameRequired'],
|
||||
['Category is required', 'server.validation.categoryRequired'],
|
||||
['An item with a recipe cannot be marked as recipe-free', 'server.validation.recipeFreeWithRecipe'],
|
||||
['Item is required', 'server.validation.itemRequired'],
|
||||
['This item is marked as recipe-free', 'server.validation.recipeFreeItem'],
|
||||
['Name is required', 'server.validation.nameRequired']
|
||||
]);
|
||||
|
||||
function validationError(message: string): ValidationError {
|
||||
const error = new Error(message) as ValidationError;
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
function cleanLocale(value: unknown): string {
|
||||
const locale = typeof value === 'string' ? value.trim() : '';
|
||||
return localePattern.test(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
function requireLocale(value: unknown): string {
|
||||
const locale = typeof value === 'string' ? value.trim() : '';
|
||||
if (!localePattern.test(locale)) {
|
||||
throw validationError('server.wordings.localeRequired');
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
function requireWordingKey(value: unknown): string {
|
||||
const key = typeof value === 'string' ? value.trim() : '';
|
||||
if (!wordingKeyPattern.test(key)) {
|
||||
throw validationError('server.wordings.keyNotFound');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function cleanSurface(value: unknown): SystemWordingSurface | '' {
|
||||
const surface = typeof value === 'string' ? value.trim() : '';
|
||||
return surfaces.has(surface as SystemWordingSurface) ? (surface as SystemWordingSurface) : '';
|
||||
}
|
||||
|
||||
function collectPlaceholders(value: string): string[] {
|
||||
return [...new Set([...value.matchAll(placeholderPattern)].map((match) => match[1]))].sort();
|
||||
}
|
||||
|
||||
function placeholdersMatch(first: string[], second: string[]): boolean {
|
||||
return first.length === second.length && first.every((placeholder, index) => placeholder === second[index]);
|
||||
}
|
||||
|
||||
function interpolate(message: string, params: Record<string, string | number>): string {
|
||||
return Object.entries(params).reduce(
|
||||
(nextMessage, [key, value]) => nextMessage.replaceAll(`{${key}}`, String(value)),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
function setNestedMessage(target: SystemWordingTree, key: string, value: string): void {
|
||||
const parts = key.split('.');
|
||||
let node = target;
|
||||
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const current = node[part];
|
||||
if (typeof current !== 'object' || current === null) {
|
||||
node[part] = {};
|
||||
}
|
||||
node = node[part] as SystemWordingTree;
|
||||
}
|
||||
|
||||
node[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function nestedMessages(rows: Array<{ key: string; value: string }>): SystemWordingTree {
|
||||
const messages: SystemWordingTree = {};
|
||||
for (const row of rows) {
|
||||
setNestedMessage(messages, row.key, row.value);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function normalizePlaceholders(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
|
||||
}
|
||||
|
||||
function legacyMessageKey(message: string): string | null {
|
||||
if (message.startsWith('server.') || message.startsWith('email.')) {
|
||||
return message;
|
||||
}
|
||||
if (message.endsWith(' must be a non-negative integer')) {
|
||||
return 'server.validation.statNonNegative';
|
||||
}
|
||||
if (message.endsWith(' is empty')) {
|
||||
return 'server.validation.pokemonDataFileEmpty';
|
||||
}
|
||||
if (message.startsWith('Pokemon data file ') && message.endsWith(' is unavailable')) {
|
||||
return 'server.validation.pokemonDataFileUnavailable';
|
||||
}
|
||||
return legacyMessageKeys.get(message) ?? null;
|
||||
}
|
||||
|
||||
export async function syncSystemWordingCatalog(): Promise<void> {
|
||||
const entries = systemWordingCatalogEntries();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const entry of entries) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO system_wording_keys (key, module, surface, description, placeholders)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET module = EXCLUDED.module,
|
||||
surface = EXCLUDED.surface,
|
||||
description = EXCLUDED.description,
|
||||
placeholders = EXCLUDED.placeholders,
|
||||
updated_at = now()
|
||||
`,
|
||||
[entry.key, entry.module, entry.surface, entry.description, JSON.stringify(entry.placeholders)]
|
||||
);
|
||||
|
||||
for (const [locale, value] of Object.entries(entry.values)) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO system_wording_values (key, locale, value)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key, locale) DO NOTHING
|
||||
`,
|
||||
[entry.key, locale, value]
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function systemMessage(
|
||||
locale: string,
|
||||
key: string,
|
||||
params: Record<string, string | number> = {}
|
||||
): Promise<string> {
|
||||
const requestedLocale = cleanLocale(locale);
|
||||
|
||||
try {
|
||||
const result = await pool.query<{ value: string }>(
|
||||
`
|
||||
SELECT COALESCE(requested.value, fallback.value) AS value
|
||||
FROM system_wording_keys k
|
||||
LEFT JOIN system_wording_values requested
|
||||
ON requested.key = k.key
|
||||
AND requested.locale = $2
|
||||
LEFT JOIN system_wording_values fallback
|
||||
ON fallback.key = k.key
|
||||
AND fallback.locale = $3
|
||||
WHERE k.key = $1
|
||||
`,
|
||||
[key, requestedLocale, defaultLocale]
|
||||
);
|
||||
const message = result.rows[0]?.value ?? systemWordingFallback(key, requestedLocale) ?? key;
|
||||
return interpolate(message, params);
|
||||
} catch {
|
||||
return interpolate(systemWordingFallback(key, requestedLocale) ?? key, params);
|
||||
}
|
||||
}
|
||||
|
||||
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
|
||||
const key = legacyMessageKey(message);
|
||||
return key ? systemMessage(locale, key) : message;
|
||||
}
|
||||
|
||||
export async function getSystemWordings(locale: string) {
|
||||
const requestedLocale = cleanLocale(locale);
|
||||
const rows = await query<{ key: string; value: string; missing: boolean }>(
|
||||
`
|
||||
SELECT
|
||||
k.key,
|
||||
COALESCE(requested.value, fallback.value, '') AS value,
|
||||
($1 <> $2 AND requested.value IS NULL) AS missing
|
||||
FROM system_wording_keys k
|
||||
LEFT JOIN system_wording_values requested
|
||||
ON requested.key = k.key
|
||||
AND requested.locale = $1
|
||||
LEFT JOIN system_wording_values fallback
|
||||
ON fallback.key = k.key
|
||||
AND fallback.locale = $2
|
||||
WHERE k.enabled = true
|
||||
ORDER BY k.key
|
||||
`,
|
||||
[requestedLocale, defaultLocale]
|
||||
);
|
||||
|
||||
return {
|
||||
locale: requestedLocale,
|
||||
fallbackLocale: defaultLocale,
|
||||
messages: nestedMessages(rows),
|
||||
missingKeys: rows.filter((row) => row.missing).map((row) => row.key)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSystemWordingRows(filters: Record<string, unknown>) {
|
||||
const locale = cleanLocale(filters.locale);
|
||||
const module = typeof filters.module === 'string' ? filters.module.trim() : '';
|
||||
const surface = cleanSurface(filters.surface);
|
||||
const missingOnly = filters.missing === 'true' || filters.missing === true;
|
||||
|
||||
return query<SystemWordingValueRow>(
|
||||
`
|
||||
SELECT
|
||||
k.key,
|
||||
k.module,
|
||||
k.surface,
|
||||
k.description,
|
||||
k.placeholders,
|
||||
COALESCE(requested.value, '') AS value,
|
||||
COALESCE(fallback.value, '') AS "defaultValue",
|
||||
($1 <> $2 AND requested.value IS NULL) AS missing,
|
||||
requested.updated_at AS "updatedAt",
|
||||
CASE
|
||||
WHEN updated_user.id IS NULL THEN NULL
|
||||
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
||||
END AS "updatedBy"
|
||||
FROM system_wording_keys k
|
||||
LEFT JOIN system_wording_values requested
|
||||
ON requested.key = k.key
|
||||
AND requested.locale = $1
|
||||
LEFT JOIN system_wording_values fallback
|
||||
ON fallback.key = k.key
|
||||
AND fallback.locale = $2
|
||||
LEFT JOIN users updated_user ON updated_user.id = requested.updated_by_user_id
|
||||
WHERE k.enabled = true
|
||||
AND ($3 = '' OR k.module = $3)
|
||||
AND ($4 = '' OR k.surface = $4)
|
||||
AND ($5 = false OR ($1 <> $2 AND requested.value IS NULL))
|
||||
ORDER BY k.module, k.key
|
||||
`,
|
||||
[locale, defaultLocale, module, surface, missingOnly]
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSystemWordingValue(keyValue: string, payload: Record<string, unknown>, userId: number) {
|
||||
const key = requireWordingKey(keyValue);
|
||||
const locale = requireLocale(payload.locale);
|
||||
const value = typeof payload.value === 'string' ? payload.value.trim() : '';
|
||||
|
||||
const keyRow = await pool.query<{ placeholders: unknown }>('SELECT placeholders FROM system_wording_keys WHERE key = $1', [key]);
|
||||
const placeholders = normalizePlaceholders(keyRow.rows[0]?.placeholders);
|
||||
if (keyRow.rowCount === 0) {
|
||||
throw validationError('server.wordings.keyNotFound');
|
||||
}
|
||||
|
||||
if (locale === defaultLocale && value === '') {
|
||||
throw validationError('server.wordings.valueRequired');
|
||||
}
|
||||
|
||||
if (value !== '' && !placeholdersMatch(placeholders, collectPlaceholders(value))) {
|
||||
throw validationError('server.wordings.placeholderMismatch');
|
||||
}
|
||||
|
||||
const result = await pool.query<{ code: string }>('SELECT code FROM languages WHERE code = $1', [locale]);
|
||||
if (result.rowCount === 0) {
|
||||
throw validationError('server.wordings.localeRequired');
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
await pool.query('DELETE FROM system_wording_values WHERE key = $1 AND locale = $2', [key, locale]);
|
||||
} else {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO system_wording_values (key, locale, value, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $4)
|
||||
ON CONFLICT (key, locale) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[key, locale, value, userId]
|
||||
);
|
||||
}
|
||||
|
||||
return listSystemWordingRows({ locale });
|
||||
}
|
||||
@@ -10,5 +10,5 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "../system-wordings.ts"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ services:
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
environment:
|
||||
VITE_API_BASE_URL: http://localhost:3001
|
||||
ports:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json ./
|
||||
RUN corepack enable && pnpm install
|
||||
COPY . .
|
||||
COPY frontend/. .
|
||||
COPY package.json /app/package.json
|
||||
COPY system-wordings.ts /app/system-wordings.ts
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
iconPokemon,
|
||||
iconRecipe
|
||||
} 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';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
@@ -87,12 +87,15 @@ async function loadLanguages() {
|
||||
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
||||
setCurrentLocale('en');
|
||||
}
|
||||
|
||||
await loadSystemWordings(getCurrentLocale());
|
||||
} catch {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Icon } from '@iconify/vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
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 PokeBallMark from './PokeBallMark.vue';
|
||||
import StatusBadge from './StatusBadge.vue';
|
||||
@@ -184,7 +184,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<Icon :icon="iconLogout" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('nav.logout') }}
|
||||
|
||||
@@ -15,6 +15,8 @@ const changeLabelKeys: Record<string, string> = {
|
||||
Genus: 'pages.pokemon.genus',
|
||||
Details: 'pages.pokemon.details',
|
||||
介绍: 'pages.pokemon.details',
|
||||
Image: 'pages.pokemon.image',
|
||||
图片: 'pages.pokemon.image',
|
||||
Height: 'pages.pokemon.height',
|
||||
身高: 'pages.pokemon.height',
|
||||
Weight: 'pages.pokemon.weight',
|
||||
|
||||
@@ -9,13 +9,15 @@ defineProps<{
|
||||
to?: string;
|
||||
icon?: AppIcon;
|
||||
marker?: string;
|
||||
image?: { src: string; alt: string };
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
||||
<span class="entity-card__mark">
|
||||
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||
<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" />
|
||||
<span v-else>{{ marker }}</span>
|
||||
</span>
|
||||
@@ -27,8 +29,9 @@ defineProps<{
|
||||
</RouterLink>
|
||||
|
||||
<article v-else class="entity-card">
|
||||
<span class="entity-card__mark">
|
||||
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||
<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" />
|
||||
<span v-else>{{ marker }}</span>
|
||||
</span>
|
||||
|
||||
1017
frontend/src/i18n.ts
1017
frontend/src/i18n.ts
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ export const iconEvent: AppIcon = 'mdi:calendar-star';
|
||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||
export const iconInfo: AppIcon = 'mdi:information-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 iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
|
||||
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 iconNoRecipe: AppIcon = 'mdi:file-document-remove-outline';
|
||||
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 iconRegister: AppIcon = 'mdi:account-plus-outline';
|
||||
export const iconReply: AppIcon = 'mdi:reply-outline';
|
||||
|
||||
@@ -11,8 +11,11 @@ import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import UserProfileView from '../views/UserProfileView.vue';
|
||||
import RegisterView from '../views/RegisterView.vue';
|
||||
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||
|
||||
@@ -44,7 +47,10 @@ export const router = createRouter({
|
||||
{ path: '/checklist', component: DailyChecklistView },
|
||||
{ path: '/life', component: LifeView },
|
||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true } },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||
{ path: '/reset-password', component: ResetPasswordView },
|
||||
{ path: '/register', component: RegisterView },
|
||||
{ path: '/verify-email', component: VerifyEmailView }
|
||||
],
|
||||
@@ -56,7 +62,10 @@ export const router = createRouter({
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -66,7 +75,7 @@ router.beforeEach(async (to) => {
|
||||
|
||||
try {
|
||||
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 {
|
||||
setAuthToken(null);
|
||||
return { path: '/login', query: { redirect: to.fullPath } };
|
||||
|
||||
@@ -15,6 +15,21 @@ export interface Language {
|
||||
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 {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -35,6 +50,15 @@ export interface PokemonStats {
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface PokemonImage {
|
||||
path: string;
|
||||
url: string;
|
||||
style: string;
|
||||
version: string;
|
||||
variant: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number;
|
||||
displayName: string;
|
||||
@@ -74,6 +98,7 @@ export interface Pokemon extends EditInfo {
|
||||
heightMeters: number;
|
||||
weightPounds: number;
|
||||
weightKg: number;
|
||||
image: PokemonImage | null;
|
||||
translations?: TranslationMap;
|
||||
types: NamedEntity[];
|
||||
stats: PokemonStats;
|
||||
@@ -249,9 +274,14 @@ export interface AuthUser {
|
||||
emailVerified: boolean;
|
||||
}
|
||||
|
||||
export interface UserProfilePayload {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface LoginPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
rememberMe?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisterPayload extends LoginPayload {
|
||||
@@ -288,6 +318,7 @@ export interface PokemonPayload {
|
||||
skillIds: number[];
|
||||
favoriteThingIds: number[];
|
||||
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
export interface PokemonFetchResult {
|
||||
@@ -308,6 +339,12 @@ export interface PokemonFetchOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PokemonImageOptionsResult {
|
||||
id: number;
|
||||
identifier: string;
|
||||
images: PokemonImage[];
|
||||
}
|
||||
|
||||
export interface ItemPayload {
|
||||
name: string;
|
||||
translations?: TranslationMap;
|
||||
@@ -386,26 +423,37 @@ export function buildQuery(params: Record<string, string | number | undefined>):
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
export function getAuthToken(): string | null {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
function authStorage(type: 'local' | 'session'): Storage | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localStorage.getItem(authTokenKey);
|
||||
return type === 'local' ? window.localStorage : window.sessionStorage;
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null): void {
|
||||
if (typeof localStorage === 'undefined') {
|
||||
return;
|
||||
export function getAuthToken(): string | null {
|
||||
const sessionToken = authStorage('session')?.getItem(authTokenKey);
|
||||
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) {
|
||||
localStorage.setItem(authTokenKey, token);
|
||||
if (options.persistent === false) {
|
||||
session?.setItem(authTokenKey, token);
|
||||
local?.removeItem(authTokenKey);
|
||||
} 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 {
|
||||
@@ -413,6 +461,12 @@ export function onAuthTokenChange(callback: () => void): () => void {
|
||||
return () => window.removeEventListener(authChangeEvent, callback);
|
||||
}
|
||||
|
||||
export function notifyAuthChange(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new Event(authChangeEvent));
|
||||
}
|
||||
}
|
||||
|
||||
function requestHeaders(): HeadersInit {
|
||||
const token = getAuthToken();
|
||||
return {
|
||||
@@ -447,7 +501,7 @@ async function getJson<T>(path: string, signal?: AbortSignal): 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}`, {
|
||||
method,
|
||||
headers: {
|
||||
@@ -508,11 +562,20 @@ export const api = {
|
||||
sendJson<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
|
||||
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
|
||||
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),
|
||||
verifyEmail: (token: string) =>
|
||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||
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'),
|
||||
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
dailyChecklist: () => getJson<DailyChecklistItem[]>('/api/daily-checklist'),
|
||||
@@ -572,6 +635,8 @@ export const api = {
|
||||
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
|
||||
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
|
||||
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),
|
||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||
|
||||
@@ -364,11 +364,43 @@ svg {
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
min-height: 44px;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow: hidden;
|
||||
padding: 8px 10px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--surface);
|
||||
color: var(--ink-soft);
|
||||
font-size: 14px;
|
||||
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;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -751,6 +783,13 @@ button:disabled,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pokemon-fetch-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pokemon-fetch-panel__button {
|
||||
min-width: 118px;
|
||||
justify-content: center;
|
||||
@@ -812,6 +851,115 @@ button:disabled,
|
||||
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 {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -1305,11 +1453,24 @@ button:disabled,
|
||||
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 {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.entity-card__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.entity-card__content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -1332,6 +1493,33 @@ button:disabled,
|
||||
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 {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
@@ -2409,6 +2597,133 @@ button:disabled,
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -3064,6 +3379,58 @@ button:disabled,
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
@@ -3071,6 +3438,21 @@ button:disabled,
|
||||
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-row {
|
||||
display: grid;
|
||||
@@ -3092,6 +3474,36 @@ button:disabled,
|
||||
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 {
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
@@ -3404,6 +3816,27 @@ button:disabled,
|
||||
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 {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
@@ -3429,6 +3862,103 @@ button:disabled,
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
|
||||
@@ -3748,13 +4278,27 @@ button:disabled,
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.pokemon-image-detail,
|
||||
.pokemon-profile-grid,
|
||||
.pokemon-profile-row,
|
||||
.pokemon-related-grid,
|
||||
.profile-layout,
|
||||
.system-wording-layout,
|
||||
.admin-layout {
|
||||
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 {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
@@ -3812,6 +4356,20 @@ button:disabled,
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 18px;
|
||||
@@ -3840,6 +4398,32 @@ button:disabled,
|
||||
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 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
iconTranslate,
|
||||
type AppIcon
|
||||
} from '../icons';
|
||||
import { defaultLocale, getCurrentLocale, setCurrentLocale } from '../i18n';
|
||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||
import {
|
||||
api,
|
||||
type AuthUser,
|
||||
@@ -37,15 +37,18 @@ import {
|
||||
type Pokemon,
|
||||
type Recipe,
|
||||
type Skill,
|
||||
type SystemWording,
|
||||
type SystemWordingSurface,
|
||||
type TranslationMap
|
||||
} 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 };
|
||||
|
||||
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
config: iconAdmin,
|
||||
languages: iconTranslate,
|
||||
wordings: iconTranslate,
|
||||
checklist: iconChecklist,
|
||||
pokemon: iconPokemon,
|
||||
items: iconItem,
|
||||
@@ -58,6 +61,7 @@ const { locale, t } = useI18n();
|
||||
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||
{ key: 'config', label: t('pages.admin.config') },
|
||||
{ key: 'languages', label: t('pages.admin.languages') },
|
||||
{ key: 'wordings', label: t('pages.admin.wordings') },
|
||||
{ key: 'checklist', label: t('pages.admin.checklist') },
|
||||
{ key: 'pokemon', label: 'Pokemon' },
|
||||
{ key: 'items', label: t('pages.items.title') },
|
||||
@@ -86,6 +90,7 @@ const pokemonRows = ref<Pokemon[]>([]);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const recipeRows = ref<Recipe[]>([]);
|
||||
const habitatRows = ref<Habitat[]>([]);
|
||||
const wordingRows = ref<SystemWording[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const busy = 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 checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||
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 configModalOpen = ref(false);
|
||||
const checklistModalOpen = 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 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 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 checklistLabel = (item: DailyChecklistItem) => item.title;
|
||||
const languageKey = (item: Language) => item.code;
|
||||
@@ -197,6 +238,14 @@ function resetLanguageForm() {
|
||||
editingLanguageCode.value = '';
|
||||
}
|
||||
|
||||
function resetWordingForm() {
|
||||
wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] };
|
||||
}
|
||||
|
||||
function selectWordingModule(module: string) {
|
||||
wordingModule.value = module;
|
||||
}
|
||||
|
||||
function openNewConfig() {
|
||||
resetConfigForm();
|
||||
configModalOpen.value = true;
|
||||
@@ -237,6 +286,11 @@ function closeLanguageModal() {
|
||||
resetLanguageForm();
|
||||
}
|
||||
|
||||
function closeWordingModal() {
|
||||
wordingModalOpen.value = false;
|
||||
resetWordingForm();
|
||||
}
|
||||
|
||||
function editLanguage(item: Language) {
|
||||
editingLanguageCode.value = item.code;
|
||||
languageForm.value = {
|
||||
@@ -249,6 +303,17 @@ function editLanguage(item: Language) {
|
||||
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) {
|
||||
const nextTranslations: TranslationMap = { ...configForm.value.translations };
|
||||
const nextFields = { ...(nextTranslations[localeCode] ?? {}) };
|
||||
@@ -456,6 +521,7 @@ async function saveLanguage() {
|
||||
? await api.updateLanguage(editingLanguageCode.value, payload)
|
||||
: await api.createLanguage(payload);
|
||||
closeLanguageModal();
|
||||
await loadSystemWordings(getCurrentLocale(), true);
|
||||
setCurrentLocale(getCurrentLocale());
|
||||
});
|
||||
}
|
||||
@@ -484,6 +550,32 @@ async function loadHabitats() {
|
||||
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) {
|
||||
if (showSkeleton) {
|
||||
contentLoading.value = true;
|
||||
@@ -492,6 +584,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
||||
try {
|
||||
if (activeTab.value === 'config') await loadConfig();
|
||||
if (activeTab.value === 'languages') await loadLanguages();
|
||||
if (activeTab.value === 'wordings') await loadWordings();
|
||||
if (activeTab.value === 'checklist') await loadChecklist();
|
||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||
if (activeTab.value === 'items') await loadItems();
|
||||
@@ -739,6 +832,79 @@ onMounted(() => {
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</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">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ReorderableList
|
||||
@@ -932,5 +1098,39 @@ onMounted(() => {
|
||||
</button>
|
||||
</template>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
59
frontend/src/views/ForgotPasswordView.vue
Normal file
59
frontend/src/views/ForgotPasswordView.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconMail } from '../icons';
|
||||
import { api } from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const email = ref('');
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
|
||||
async function submitResetRequest() {
|
||||
busy.value = true;
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await api.requestPasswordReset({ email: email.value });
|
||||
message.value = response.message;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.requestResetFailed');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-page">
|
||||
<div class="auth-panel">
|
||||
<PageHeader :title="t('auth.requestResetTitle')" :subtitle="t('auth.requestResetSubtitle')">
|
||||
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||
</PageHeader>
|
||||
|
||||
<form class="auth-form" @submit.prevent="submitResetRequest">
|
||||
<div class="field">
|
||||
<label for="forgot-password-email">{{ t('auth.email') }}</label>
|
||||
<input id="forgot-password-email" v-model="email" autocomplete="email" required type="email" />
|
||||
</div>
|
||||
|
||||
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||
|
||||
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||
<Icon :icon="iconMail" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('auth.sending') : t('auth.sendResetLink') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-switch">
|
||||
<RouterLink to="/login">{{ t('auth.goLogin') }}</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -13,6 +13,7 @@ const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
const rememberMe = ref(false);
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
|
||||
@@ -23,9 +24,10 @@ async function submitLogin() {
|
||||
try {
|
||||
const response = await api.login({
|
||||
email: email.value,
|
||||
password: password.value
|
||||
password: password.value,
|
||||
rememberMe: rememberMe.value
|
||||
});
|
||||
setAuthToken(response.token);
|
||||
setAuthToken(response.token, { persistent: rememberMe.value });
|
||||
|
||||
const redirect =
|
||||
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||
@@ -44,7 +46,7 @@ async function submitLogin() {
|
||||
<section class="auth-page">
|
||||
<div class="auth-panel">
|
||||
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
|
||||
<template #kicker>Trainer Pass</template>
|
||||
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||
</PageHeader>
|
||||
|
||||
<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" />
|
||||
</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>
|
||||
|
||||
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||
|
||||
@@ -7,6 +7,7 @@ import DetailSection from '../components/DetailSection.vue';
|
||||
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import Modal from '../components/Modal.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
@@ -21,6 +22,7 @@ const pokemon = ref<PokemonDetail | null>(null);
|
||||
const itemCategoryTab = ref('');
|
||||
const relatedHabitatTab = ref('');
|
||||
const detailTab = ref('details');
|
||||
const imageModalOpen = ref(false);
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const relatedPokemonLimit = 6;
|
||||
@@ -184,6 +186,22 @@ function pokemonTypeIconSrc(typeId: number): string | 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() {
|
||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||
pokemon.value = nextPokemon;
|
||||
@@ -209,6 +227,7 @@ watch(
|
||||
pokemon.value = null;
|
||||
relatedHabitatTab.value = '';
|
||||
detailTab.value = 'details';
|
||||
imageModalOpen.value = false;
|
||||
void loadPokemonDetail();
|
||||
}
|
||||
);
|
||||
@@ -290,7 +309,7 @@ watch(
|
||||
<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 class="pokemon-profile-grid">
|
||||
<div class="pokemon-profile-grid" :class="{ 'pokemon-profile-grid--with-image': pokemon.image }">
|
||||
<div class="pokemon-profile-main">
|
||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
||||
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
|
||||
@@ -333,9 +352,15 @@ watch(
|
||||
</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')">
|
||||
<PokemonStatsPanel :stats="pokemon.stats" />
|
||||
</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>
|
||||
|
||||
<DetailSection :title="t('pages.pokemon.skills')">
|
||||
@@ -463,5 +488,25 @@ watch(
|
||||
</div>
|
||||
</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" />
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type Options,
|
||||
type PokemonFetchOption,
|
||||
type PokemonFetchResult,
|
||||
type PokemonImage,
|
||||
type PokemonPayload,
|
||||
type PokemonStats,
|
||||
type TranslationMap
|
||||
@@ -38,11 +39,14 @@ const languages = ref<Language[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const fetchBusy = ref(false);
|
||||
const imageBusy = ref(false);
|
||||
const fetchOptionsLoading = ref(false);
|
||||
const fetchOptionsOpen = ref(false);
|
||||
const message = ref('');
|
||||
const fetchIdentifier = ref('');
|
||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||
const imageOptions = ref<PokemonImage[]>([]);
|
||||
const currentPokemonImage = ref<PokemonImage | null>(null);
|
||||
const creatingSelect = ref('');
|
||||
const activeEditTab = ref('basic');
|
||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
@@ -73,7 +77,8 @@ const pokemonForm = ref({
|
||||
environmentId: '',
|
||||
skillIds: [] as string[],
|
||||
favoriteThingIds: [] as string[],
|
||||
skillItemDrops: [] as SkillItemDropForm[]
|
||||
skillItemDrops: [] as SkillItemDropForm[],
|
||||
imagePath: ''
|
||||
});
|
||||
|
||||
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 weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
|
||||
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[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -261,8 +282,11 @@ async function loadEditor() {
|
||||
skillItemDrops: pokemon.skills.map((skill) => ({
|
||||
skillId: String(skill.id),
|
||||
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
|
||||
}))
|
||||
})),
|
||||
imagePath: pokemon.image?.path ?? ''
|
||||
};
|
||||
currentPokemonImage.value = pokemon.image;
|
||||
imageOptions.value = pokemon.image ? [pokemon.image] : [];
|
||||
syncSkillItemDrops();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -327,6 +351,14 @@ function closeFetchOptions() {
|
||||
cancelFetchOptionsRequest();
|
||||
}
|
||||
|
||||
function handleFetchIdentifierInput() {
|
||||
fetchOptionsOpen.value = true;
|
||||
}
|
||||
|
||||
function closeFetchOptionsAfterBlur() {
|
||||
window.setTimeout(closeFetchOptions, 120);
|
||||
}
|
||||
|
||||
async function selectFetchOption(option: PokemonFetchOption) {
|
||||
fetchIdentifier.value = option.identifier;
|
||||
closeFetchOptions();
|
||||
@@ -361,6 +393,65 @@ function fetchPokemonFromInput() {
|
||||
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) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
@@ -399,7 +490,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
||||
}
|
||||
|
||||
async function savePokemon() {
|
||||
if (fetchBusy.value) {
|
||||
if (fetchBusy.value || imageBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,7 +518,8 @@ async function savePokemon() {
|
||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||
skillItemDrops: selectedSkillDropRows.value
|
||||
.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);
|
||||
await router.push(`/pokemon/${saved.id}`);
|
||||
@@ -467,7 +559,9 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
role="combobox"
|
||||
:aria-expanded="fetchOptionsOpen"
|
||||
aria-controls="pokemon-fetch-results"
|
||||
@focus="openFetchOptions"
|
||||
@click="openFetchOptions"
|
||||
@input="handleFetchIdentifierInput"
|
||||
@blur="closeFetchOptionsAfterBlur"
|
||||
@keydown.escape.stop="closeFetchOptions"
|
||||
@keydown.enter.prevent="fetchPokemonFromInput"
|
||||
/>
|
||||
@@ -490,10 +584,16 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
<p v-else class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchNoMatches') }}</p>
|
||||
</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">
|
||||
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||
{{ fetchBusy ? t('pages.pokemon.fetchingData') : t('pages.pokemon.fetchData') }}
|
||||
</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>
|
||||
|
||||
<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 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 v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
||||
@@ -681,7 +822,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
</section>
|
||||
|
||||
<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" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import EditMeta from '../components/EditMeta.vue';
|
||||
import EntityChips from '../components/EntityChips.vue';
|
||||
import EntityCard from '../components/EntityCard.vue';
|
||||
import FilterPanel from '../components/FilterPanel.vue';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
@@ -44,8 +42,8 @@ async function loadPokemon() {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
function pokemonTypeIconSrc(typeId: number): string | null {
|
||||
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
||||
function pokemonCardImage(item: Pokemon) {
|
||||
return item.image ? { src: item.image.url, alt: t('pages.pokemon.imageAlt', { name: item.name, variant: item.image.variant }) } : undefined;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -124,40 +122,22 @@ watch(query, loadPokemon);
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
<Skeleton width="76%" 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>
|
||||
<Skeleton width="128px" height="24px" />
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="entity-grid">
|
||||
<div v-else class="entity-grid pokemon-list-grid">
|
||||
<EntityCard
|
||||
v-for="item in pokemon"
|
||||
:key="item.id"
|
||||
:title="`#${item.id} ${item.name}`"
|
||||
:subtitle="t('pages.pokemon.environmentPrefix', { name: item.environment.name })"
|
||||
:to="`/pokemon/${item.id}`"
|
||||
>
|
||||
<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>
|
||||
:image="pokemonCardImage(item)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PokemonEdit v-if="showEditor" />
|
||||
|
||||
98
frontend/src/views/ResetPasswordView.vue
Normal file
98
frontend/src/views/ResetPasswordView.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconKey, iconLogin } from '../icons';
|
||||
import { api } from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const password = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
|
||||
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
|
||||
|
||||
async function submitPasswordReset() {
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
|
||||
if (!token.value) {
|
||||
errorMessage.value = t('auth.invalidPasswordReset');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = t('auth.passwordMismatch');
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
|
||||
try {
|
||||
const response = await api.resetPassword({ token: token.value, password: password.value });
|
||||
message.value = response.message;
|
||||
password.value = '';
|
||||
confirmPassword.value = '';
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.resetFailed');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-page">
|
||||
<div class="auth-panel">
|
||||
<PageHeader :title="t('auth.resetTitle')" :subtitle="t('auth.resetSubtitle')">
|
||||
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||
</PageHeader>
|
||||
|
||||
<form v-if="!message" class="auth-form" @submit.prevent="submitPasswordReset">
|
||||
<div class="field">
|
||||
<label for="reset-password">{{ t('auth.newPassword') }}</label>
|
||||
<input
|
||||
id="reset-password"
|
||||
v-model="password"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="reset-password-confirm">{{ t('auth.confirmPassword') }}</label>
|
||||
<input
|
||||
id="reset-password-confirm"
|
||||
v-model="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||
|
||||
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||
<Icon :icon="iconKey" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('auth.resetting') : t('auth.resetPassword') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<StatusMessage v-else variant="success">{{ message }}</StatusMessage>
|
||||
|
||||
<RouterLink v-if="message" class="ui-button ui-button--ghost" to="/login">
|
||||
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('auth.goLogin') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
160
frontend/src/views/UserProfileView.vue
Normal file
160
frontend/src/views/UserProfileView.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import Skeleton from '../components/Skeleton.vue';
|
||||
import StatusBadge from '../components/StatusBadge.vue';
|
||||
import StatusMessage from '../components/StatusMessage.vue';
|
||||
import { iconProfile, iconSave } from '../icons';
|
||||
import { api, notifyAuthChange, type AuthUser } from '../services/api';
|
||||
|
||||
const { t } = useI18n();
|
||||
const user = ref<AuthUser | null>(null);
|
||||
const displayName = ref('');
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const message = ref('');
|
||||
const errorMessage = ref('');
|
||||
|
||||
const trimmedDisplayName = computed(() => displayName.value.trim());
|
||||
const hasChanges = computed(() => {
|
||||
const currentUser = user.value;
|
||||
if (!currentUser) return false;
|
||||
return trimmedDisplayName.value !== currentUser.displayName;
|
||||
});
|
||||
const profileInitial = computed(() => {
|
||||
const name = user.value?.displayName.trim() || user.value?.email.trim() || '';
|
||||
return name.charAt(0).toUpperCase();
|
||||
});
|
||||
|
||||
async function loadProfile() {
|
||||
loading.value = true;
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
|
||||
try {
|
||||
const response = await api.me();
|
||||
user.value = response.user;
|
||||
displayName.value = response.user.displayName;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveProfile() {
|
||||
message.value = '';
|
||||
errorMessage.value = '';
|
||||
|
||||
if (!trimmedDisplayName.value) {
|
||||
errorMessage.value = t('pages.profile.displayNameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
busy.value = true;
|
||||
try {
|
||||
const response = await api.updateMe({ displayName: trimmedDisplayName.value });
|
||||
user.value = response.user;
|
||||
displayName.value = response.user.displayName;
|
||||
message.value = t('pages.profile.saved');
|
||||
notifyAuthChange();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.saveFailed');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadProfile();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="profile-page">
|
||||
<PageHeader :title="t('pages.profile.title')" :subtitle="t('pages.profile.subtitle')">
|
||||
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||
</PageHeader>
|
||||
|
||||
<div v-if="loading" class="profile-layout" aria-busy="true" :aria-label="t('pages.profile.loading')">
|
||||
<section class="profile-card profile-card--identity" aria-hidden="true">
|
||||
<div class="profile-identity">
|
||||
<Skeleton variant="box" width="58px" height="58px" />
|
||||
<div class="profile-identity__copy">
|
||||
<Skeleton width="160px" height="28px" />
|
||||
<Skeleton width="220px" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="profile-card" aria-hidden="true">
|
||||
<Skeleton width="180px" height="28px" />
|
||||
<div class="auth-form">
|
||||
<div class="field">
|
||||
<Skeleton width="110px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<Skeleton width="70px" />
|
||||
<Skeleton variant="box" height="44px" />
|
||||
</div>
|
||||
<Skeleton variant="box" width="120px" height="42px" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="user" class="profile-layout">
|
||||
<section class="profile-card profile-card--identity" :aria-label="t('pages.profile.accountSummary')">
|
||||
<div class="profile-identity">
|
||||
<div class="profile-avatar" aria-hidden="true">{{ profileInitial }}</div>
|
||||
<div class="profile-identity__copy">
|
||||
<h2>{{ user.displayName }}</h2>
|
||||
<p>{{ user.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge
|
||||
:label="user.emailVerified ? t('pages.profile.emailVerified') : t('pages.profile.emailUnverified')"
|
||||
:tone="user.emailVerified ? 'success' : 'warning'"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="profile-card" :aria-label="t('pages.profile.profileDetails')">
|
||||
<div class="profile-card__header">
|
||||
<Icon :icon="iconProfile" class="profile-card__icon" aria-hidden="true" />
|
||||
<h2>{{ t('pages.profile.profileDetails') }}</h2>
|
||||
</div>
|
||||
|
||||
<form class="auth-form" @submit.prevent="saveProfile">
|
||||
<div class="field">
|
||||
<label for="profile-display-name">{{ t('auth.displayName') }}</label>
|
||||
<input
|
||||
id="profile-display-name"
|
||||
v-model="displayName"
|
||||
autocomplete="nickname"
|
||||
maxlength="40"
|
||||
required
|
||||
:disabled="busy"
|
||||
/>
|
||||
<small class="profile-field-note">{{ t('pages.profile.displayNameHint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="profile-email">{{ t('auth.email') }}</label>
|
||||
<input id="profile-email" class="profile-readonly-input" :value="user.email" readonly type="email" />
|
||||
</div>
|
||||
|
||||
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
|
||||
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||
|
||||
<button class="ui-button ui-button--primary" :disabled="busy || !hasChanges" type="submit">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<StatusMessage v-else-if="errorMessage" variant="danger" :duration="0">{{ errorMessage }}</StatusMessage>
|
||||
</section>
|
||||
</template>
|
||||
@@ -4,5 +4,5 @@
|
||||
"strict": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../system-wordings.ts"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "pokopia",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev",
|
||||
|
||||
1344
system-wordings.ts
Normal file
1344
system-wordings.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user