From 976a2a248269a43f895d13dd465be84e1e749669 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sat, 2 May 2026 11:48:11 +0800 Subject: [PATCH] feat(i18n): implement dynamic system wording management Add database schema and API endpoints for system wording keys and values Replace hardcoded translations in frontend and backend with dynamic messages Add System Wordings management interface to Admin view --- .dockerignore | 1 - DESIGN.md | 23 +- backend/Dockerfile | 6 +- backend/db/schema.sql | 33 + backend/src/auth.ts | 113 +-- backend/src/server.ts | 135 +-- backend/src/systemWordingQueries.ts | 348 ++++++++ backend/tsconfig.json | 2 +- docker-compose.yml | 3 +- frontend/Dockerfile | 8 +- frontend/src/App.vue | 7 +- frontend/src/i18n.ts | 1017 ++-------------------- frontend/src/services/api.ts | 19 + frontend/src/styles/main.css | 44 + frontend/src/views/AdminView.vue | 173 +++- frontend/tsconfig.json | 2 +- package.json | 1 + system-wordings.ts | 1247 +++++++++++++++++++++++++++ 18 files changed, 2095 insertions(+), 1087 deletions(-) create mode 100644 backend/src/systemWordingQueries.ts create mode 100644 system-wordings.ts diff --git a/.dockerignore b/.dockerignore index ed13c71..8a764a0 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,4 +3,3 @@ **/dist **/*.log **/.env -frontend diff --git a/DESIGN.md b/DESIGN.md index 527bd24..ea936ea 100644 --- a/DESIGN.md +++ b/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 维护能力,可按语言、模块、端和缺失状态查看并编辑系统级文案。 ## 用户与认证 @@ -486,6 +503,7 @@ API 暴露边界: 公开浏览 API: - `GET /api/languages` +- `GET /api/system-wordings` - `GET /api/options` - `GET /api/daily-checklist` - `GET /api/pokemon` @@ -530,6 +548,9 @@ API 暴露边界: - 每日 CheckList 的创建、更新、删除、排序。 - 全局配置项的创建、更新、删除、排序。 - 语言的创建、更新、删除、排序。 +- 系统级文案的查看和更新。 + - `GET /api/admin/system-wordings` + - `PUT /api/admin/system-wordings/:key` - Pokemon、物品、材料单、栖息地的列表排序。 ## 开发与验证 diff --git a/backend/Dockerfile b/backend/Dockerfile index 83c03af..3db3245 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 5b01cac..70a8b01 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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, diff --git a/backend/src/auth.ts b/backend/src/auth.ts index eed2da5..f0e15ee 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -2,6 +2,7 @@ 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; @@ -53,87 +54,64 @@ function statusError(message: string, statusCode: number): StatusError { return error; } -function authMessage(locale: string, key: AuthMessageKey, params: Record = {}): string { - const messages: Record> = { - 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: - '

Open the link below to verify your email:

Verify email

The link expires in {hours} hours.

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

请点击下面的链接完成邮箱验证:

验证邮箱

链接将在 {hours} 小时后失效。

', - emailText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。' - } +function authMessage(locale: string, key: AuthMessageKey, params: Record = {}): Promise { + const messageKeys: Record = { + 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', + invalidCredentials: 'server.auth.invalidCredentials', + verifyEmailFirst: 'server.auth.verifyEmailFirst', + emailSubject: 'email.auth.verificationSubject', + emailHtml: 'email.auth.verificationHtml', + emailText: 'email.auth.verificationText' }; - 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 { 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 { 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 { 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): Promise { if (typeof value !== 'string' || value.trim().length < 32) { - throw statusError(authMessage(locale, 'invalidToken'), 400); + throw statusError(await authMessage(locale, 'invalidToken'), 400); } return value.trim(); @@ -219,6 +197,9 @@ function buildVerificationUrl(token: string): string { async function sendVerificationEmail(email: string, token: string, locale: string): Promise { 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 +209,9 @@ 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 }) }); @@ -241,9 +222,9 @@ async function sendVerificationEmail(email: string, token: string, locale: strin } export async function registerUser(payload: Record, 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 +237,7 @@ export async function registerUser(payload: Record, 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 +276,11 @@ export async function registerUser(payload: Record, locale = de }); await sendVerificationEmail(email, verificationToken, locale); - return { message: authMessage(locale, 'checkVerificationEmail') }; + return { message: await authMessage(locale, 'checkVerificationEmail') }; } export async function verifyEmail(payload: Record, 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 +298,7 @@ export async function verifyEmail(payload: Record, locale = def ); if (!tokenRow) { - throw statusError(authMessage(locale, 'invalidToken'), 400); + throw statusError(await authMessage(locale, 'invalidToken'), 400); } const user = await clientQueryOne( @@ -332,31 +313,31 @@ export async function verifyEmail(payload: Record, 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 loginUser(payload: Record, 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 user = await queryOne( '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(); diff --git a/backend/src/server.ts b/backend/src/server.ts index 53a79e6..6e73a14 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -62,6 +62,14 @@ import { updatePokemon, updateRecipe } from './queries.ts'; +import { + getSystemWordings, + listSystemWordingRows, + localizedStatusMessage, + syncSystemWordingCatalog, + systemMessage, + updateSystemWordingValue +} from './systemWordingQueries.ts'; const app = Fastify({ logger: true @@ -78,23 +86,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 +119,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 { + 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 { @@ -140,12 +136,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; } @@ -178,7 +174,7 @@ app.get('/api/auth/me', async (request, reply) => { 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 }; @@ -195,6 +191,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 +216,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, 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 +231,7 @@ app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, request.body as Record, 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 +241,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, 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 +251,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, 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 +261,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 +271,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 +281,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 +303,7 @@ app.post('/api/discussions/:entityType/:entityId/comments', async (request, repl request.body as Record, 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 +324,7 @@ app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', a request.body as Record, 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 +335,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 +354,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; @@ -383,7 +381,7 @@ app.put('/api/pokemon/:id', async (request, reply) => { const pokemon = await updatePokemon(Number(id), request.body as Record, user.id, requestLocale(request)); if (!pokemon) { - return reply.code(404).send({ message: 'Not found' }); + return notFound(reply, request); } return pokemon; @@ -396,7 +394,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 +404,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 +426,7 @@ app.put('/api/habitats/:id', async (request, reply) => { const habitat = await updateHabitat(Number(id), request.body as Record, user.id, requestLocale(request)); if (!habitat) { - return reply.code(404).send({ message: 'Not found' }); + return notFound(reply, request); } return habitat; @@ -441,7 +439,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 +451,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 +473,7 @@ app.put('/api/items/:id', async (request, reply) => { const item = await updateItem(Number(id), request.body as Record, user.id, requestLocale(request)); if (!item) { - return reply.code(404).send({ message: 'Not found' }); + return notFound(reply, request); } return item; @@ -488,7 +486,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 +498,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 +520,7 @@ app.put('/api/recipes/:id', async (request, reply) => { const recipe = await updateRecipe(Number(id), request.body as Record, user.id, requestLocale(request)); if (!recipe) { - return reply.code(404).send({ message: 'Not found' }); + return notFound(reply, request); } return recipe; @@ -535,7 +533,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 +562,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 +572,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 +626,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) : 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, user.id); }); app.get('/api/admin/config/:type', async (request, reply) => { @@ -638,7 +650,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 +662,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 +676,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, user.id, requestLocale(request)); }); @@ -676,10 +688,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, 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 +701,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); diff --git a/backend/src/systemWordingQueries.ts b/backend/src/systemWordingQueries.ts new file mode 100644 index 0000000..4a2e05d --- /dev/null +++ b/backend/src/systemWordingQueries.ts @@ -0,0 +1,348 @@ +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(['frontend', 'backend', 'email']); + +const legacyMessageKeys = new Map([ + ['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'], + ['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 { + 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 { + 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 = {} +): Promise { + 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 { + 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) { + 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( + ` + 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, 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 }); +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json index a67af79..081230f 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -10,5 +10,5 @@ "forceConsistentCasingInFileNames": true, "types": ["node"] }, - "include": ["src/**/*.ts", "tests/**/*.ts"] + "include": ["src/**/*.ts", "tests/**/*.ts", "../system-wordings.ts"] } diff --git a/docker-compose.yml b/docker-compose.yml index 2744cc2..42669d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,8 @@ services: frontend: build: - context: ./frontend + context: . + dockerfile: frontend/Dockerfile environment: VITE_API_BASE_URL: http://localhost:3001 ports: diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 151842a..9c1efa5 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4ed9241..313bd4b 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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); } diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 5e9cd71..8bec5ac 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -1,954 +1,18 @@ import { createI18n } from 'vue-i18n'; +import { defaultLocale, systemWordingMessages, type SystemWordingTree } from '../../system-wordings'; -export const defaultLocale = 'en'; +export { defaultLocale } from '../../system-wordings'; +const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'; const localeStorageKey = 'pokopia_locale'; const localeChangeEvent = 'pokopia-locale-change'; -const messages = { - en: { - common: { - add: 'Add', - admin: 'Admin', - all: 'All', - back: 'Back', - backToList: 'Back to list', - cancel: 'Cancel', - close: 'Close', - create: 'Create', - delete: 'Delete', - edit: 'Edit', - details: 'Details', - filters: 'Filters', - loading: 'Loading', - name: 'Name', - new: 'New', - none: 'None', - save: 'Save', - saving: 'Saving', - search: 'Search', - select: 'Select', - selected: 'Selected', - system: 'System', - noRecords: 'No records', - fieldForLanguage: '{field} ({language})', - searchOrSelect: 'Search or select', - noMatches: 'No matches', - createNamed: 'Add "{name}"', - creating: 'Adding', - inDev: 'In-Dev', - removeNamed: 'Remove {name}', - quantity: 'Quantity', - required: 'Required' - }, - nav: { - pokemon: 'Pokemon', - habitats: 'Habitats', - items: 'Items', - recipes: 'Recipes', - dish: 'Dish', - events: 'Events', - actions: 'Actions', - dreamIsland: 'Dream Island', - clothes: 'Clothes', - checklist: 'CheckList', - life: 'Life', - admin: 'Admin', - main: 'Main navigation', - openMenu: 'Open navigation', - closeMenu: 'Close navigation', - language: 'Language', - login: 'Log in', - logout: 'Log out', - register: 'Register' - }, - auth: { - email: 'Email', - password: 'Password', - displayName: 'Display name', - loginTitle: 'Log in', - loginSubtitle: 'Use a verified email to enter Pokopia Wiki.', - loggingIn: 'Logging in', - loginFailed: 'Login failed', - noAccount: 'No account yet?', - registerTitle: 'Register', - registerSubtitle: 'Verify your email after creating an account.', - registerFailed: 'Registration failed', - sending: 'Sending', - sendVerification: 'Send verification email', - hasAccount: 'Already have an account?', - verifyTitle: 'Email verification', - verifySubtitle: 'You can log in after verification is complete.', - verifyingEmail: 'Verifying email', - invalidVerification: 'The verification link is invalid or expired.', - verifyFailed: 'Email verification failed', - goLogin: 'Go to login' - }, - errors: { - requestFailed: 'Request failed ({status})', - operationFailed: 'Operation failed', - loadFailed: 'Load failed', - addFailed: 'Add failed', - saveFailed: 'Save failed', - completeEmailVerification: 'Please complete email verification first.' - }, - pages: { - pokemon: { - title: 'Pokemon', - subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.', - detailKicker: 'Pokédex Detail', - editKicker: 'Pokédex Edit', - editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.', - editSections: 'Pokemon edit sections', - editTabBasic: 'Basic', - editTabAdvance: 'Advance', - newTitle: 'New Pokemon', - editTitle: 'Edit #{id} {name}', - fetchData: 'Fetch data', - fetchingData: 'Fetching', - fetchIdentifier: 'Data identifier', - fetchIdentifierPlaceholder: 'bulbasaur or 1', - fetchIdentifierRequired: 'Enter a Pokemon identifier', - fetchFailed: 'Pokemon data fetch failed', - fetchIdMismatch: 'Fetched Pokemon ID #{id} does not match this editor.', - fetchResults: 'Pokemon data results', - fetchSearching: 'Searching data', - fetchNoMatches: 'No matching Pokemon data', - fetchSearchFailed: 'Pokemon data search failed', - loadingList: 'Loading Pokemon list', - loadingDetail: 'Loading Pokemon detail', - loadingEdit: 'Loading Pokemon editor', - environmentPrefix: 'Ideal Habitat: {name}', - details: 'Details', - genus: 'Genus', - height: 'Height', - heightInput: 'Height (in)', - heightImperial: 'ft / in', - heightMetric: 'm', - feet: 'ft', - inches: 'in', - meters: 'm', - weight: 'Weight', - weightInput: 'Weight (lb)', - pounds: 'lb', - kilograms: 'kg', - measurements: 'Height & Weight', - types: 'Types', - typeOne: 'Type 1', - typeTwo: 'Type 2', - typesAndStats: 'Types & Base stats', - statsTitle: 'Base stats', - stats: { - hp: 'HP', - attack: 'Attack', - defense: 'Defense', - specialAttack: 'Special Attack', - specialDefense: 'Special Defense', - speed: 'Speed' - }, - environment: 'Ideal Habitat', - skills: 'Specialities', - skillMatchMode: 'Speciality match mode', - any: 'Any', - all: 'All', - favoriteThings: 'Favourites', - favoriteThingMatchMode: 'Favourites match mode', - skillDrops: 'Speciality drops', - skillDrop: '{name} drop', - dropItem: 'Drop item', - searchPokemon: 'Search Pokemon', - relatedPokemon: 'Related Pokemon', - relatedHabitat: 'Related Pokemon habitat', - relatedItems: 'Related items', - relatedItemCategory: 'Related item category', - habitats: 'Habitats', - namePlaceholder: 'Name', - searchTypes: 'Search types', - searchEnvironment: 'Search ideal habitats', - searchSkills: 'Search specialities', - searchFavoriteThings: 'Search favourites', - searchItems: 'Search items' - }, - habitats: { - title: 'Habitats', - subtitle: 'View recipes and Pokemon that may appear.', - detailSubtitle: 'Habitat detail', - editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.', - newTitle: 'New habitat', - editTitle: 'Edit {name}', - fallbackName: 'Habitat', - loadingList: 'Loading habitat list', - loadingDetail: 'Loading habitat detail', - loadingEdit: 'Loading habitat editor', - recipe: 'Recipe', - recipeList: 'Recipe list', - possiblePokemon: 'Possible Pokemon', - addItem: 'Add item', - addPokemon: 'Add Pokemon', - maps: 'Maps', - searchMaps: 'Search maps' - }, - items: { - title: 'Items', - subtitle: 'Browse items by category, usage, and tags.', - detailKicker: 'Item Detail', - detailSubtitle: 'Item detail', - editKicker: 'Item Edit', - editSubtitle: 'Maintain item category, usage, acquisition methods, customization, and tags.', - newTitle: 'New item', - editTitle: 'Edit {name}', - fallbackName: 'Item', - loadingList: 'Loading item list', - loadingDetail: 'Loading item detail', - loadingEdit: 'Loading item editor', - category: 'Category', - usage: 'Usage', - tags: 'Tags', - acquisitionMethods: 'Acquisition methods', - customization: 'Customization', - dyeable: 'Dyeable', - dualDyeable: 'Dual dyeable', - patternEditable: 'Pattern editable', - noRecipe: 'No recipe', - recipeInfo: 'Recipe info', - relatedRecipes: 'Related recipes', - relatedHabitats: 'Related habitats', - pokemonDrops: 'Pokemon drops', - createRecipe: 'Create recipe', - searchCategory: 'Search categories', - searchUsage: 'Search usages', - searchMethods: 'Search acquisition methods', - searchTags: 'Search tags' - }, - recipes: { - title: 'Recipes', - subtitle: 'Browse recipes by category, usage, and tags.', - detailKicker: 'Recipe Detail', - detailSubtitle: 'Recipe detail', - editKicker: 'Recipe Edit', - editSubtitle: 'Maintain result item, acquisition methods, and materials.', - newTitle: 'New recipe', - editTitle: 'Edit {name}', - fallbackName: 'Recipe', - loadingList: 'Loading recipe list', - loadingDetail: 'Loading recipe detail', - loadingEdit: 'Loading recipe editor', - item: 'Item', - materials: 'Materials', - addMaterial: 'Add material' - }, - comingSoon: { - status: 'In development', - heading: 'This wiki section is being prepared.', - previewLabel: 'Section preview', - sections: { - dish: { - kicker: 'Dish', - title: 'Dish', - subtitle: 'A future home for cooked dishes and food discoveries.', - body: 'Dish pages are being shaped for clear browsing, source notes, and useful ingredient links.', - preview: { - one: 'Dish records will focus on names, effects, and discovery context.', - two: 'Ingredient relationships will connect back to items and recipes where useful.', - three: 'The page will stay browse-first so community edits can grow naturally.' - } - }, - events: { - kicker: 'Events', - title: 'Events', - subtitle: 'Seasonal and limited-time game activity records are coming later.', - body: 'Events will collect timing, rewards, and participation details once the section is ready.', - preview: { - one: 'Event cards will make dates and active windows easy to scan.', - two: 'Rewards and related items will sit close to the event summary.', - three: 'Archived activities will remain readable after they end.' - } - }, - actions: { - kicker: 'Actions', - title: 'Actions', - subtitle: 'Game shortcut actions such as waving and dancing will be documented here.', - body: 'Actions are being prepared as a quick reference for expressive in-game gestures and shortcuts.', - preview: { - one: 'Each action will describe the gesture or shortcut in player-facing language.', - two: 'Common examples include waving, dancing, and other social actions.', - three: 'Related unlock or usage details can be linked when the data model is ready.' - } - }, - dreamIsland: { - kicker: 'Dream Island', - title: 'Dream Island', - subtitle: 'Dream Island information is being organized for future browsing.', - body: 'This area will present island details with a calm, destination-style layout when content is ready.', - preview: { - one: 'Island notes will prioritize location, availability, and notable discoveries.', - two: 'Related Pokemon, items, or activities can be connected from the page.', - three: 'The layout will support browsing without adding another management flow yet.' - } - }, - clothes: { - kicker: 'Clothes', - title: 'Clothes', - subtitle: 'Outfit and clothing references are being prepared.', - body: 'Clothes pages will make it easy to compare appearance, acquisition, and customization details.', - preview: { - one: 'Clothing entries will focus on display names and visual categories.', - two: 'Acquisition and customization details can be connected when available.', - three: 'The page will keep item-like details readable without mixing them into the item list.' - } - } - } - }, - checklist: { - title: 'Daily checklist', - subtitle: 'See what can be completed each day.', - sectionTitle: 'Daily tasks', - empty: 'No daily checklist', - loading: 'Loading daily checklist', - task: 'Task', - newTask: 'New task', - editTask: 'Edit task' - }, - life: { - title: 'Life', - subtitle: 'Share favourite thoughts, tips, and community finds.', - kicker: 'Community Feed', - composerTitle: 'Share something', - composerPrompt: 'What would you like to share?', - bodyLabel: 'Post', - bodyPlaceholder: 'Share a thought, tip, or discovery...', - newPost: 'New Post', - tags: 'Tags', - allTags: 'All', - tagPlaceholder: 'Select tags', - searchTags: 'Search tags', - search: 'Search Life', - searchPlaceholder: 'Search post content...', - clearSearch: 'Clear search', - searchEmpty: 'No posts match your search', - searchEmptyHint: 'Try another keyword or clear the search.', - comments: 'Comments', - commentsCount: '{count} comments', - comment: 'Comment', - hideComments: 'Hide comments', - react: 'Like', - reactions: 'Reactions', - reactionsCount: '{count} reactions', - reactionCountLabel: '{reaction}: {count}', - reactionLike: 'Like', - reactionHelpful: 'Helpful', - reactionFun: 'Fun', - reactionThanks: 'Thanks', - chooseReaction: 'Choose reaction', - reactionMenu: 'Reaction menu', - removeReaction: 'Remove reaction', - reactionFailed: 'Reaction failed', - commentPlaceholder: 'Write a comment...', - commentReplyPlaceholder: 'Write a reply...', - postComment: 'Post comment', - postingComment: 'Posting comment', - reply: 'Reply', - postReply: 'Post reply', - postingReply: 'Posting reply', - cancelReply: 'Cancel reply', - noComments: 'No comments yet', - deleteComment: 'Delete comment', - deleteCommentConfirm: 'Delete this comment?', - commentDeleted: 'Comment deleted', - commentRequired: 'Please enter a comment.', - commentFailed: 'Comment failed', - replyFailed: 'Reply failed', - deleteCommentFailed: 'Delete comment failed', - publish: 'Post', - publishing: 'Posting', - update: 'Update', - updating: 'Updating', - cancelEdit: 'Cancel edit', - empty: 'No posts yet', - emptyHint: 'Verified members can start the first Life post.', - loading: 'Loading Life feed', - retryFeed: 'Retry loading', - loginPrompt: 'Log in with a verified email to post.', - verifyPrompt: 'Complete email verification to post.', - editPost: 'Edit post', - deletePost: 'Delete post', - saveEdit: 'Save edit', - postFailed: 'Post failed', - saveFailed: 'Save failed', - deleteFailed: 'Delete failed', - bodyRequired: 'Please enter a post.', - byUnknown: 'Community member', - edited: 'Edited', - deleteConfirm: 'Delete this post?', - charactersLeft: '{count} characters left' - }, - admin: { - title: 'Admin', - subtitle: 'Maintain system configuration and manage Wiki records.', - modules: 'Admin modules', - loading: 'Loading admin list', - config: 'System config', - configType: 'System config type', - checklist: 'CheckList', - pokemonList: 'Pokemon list', - itemList: 'Item list', - recipeList: 'Recipe list', - habitatList: 'Habitat list', - languages: 'Languages', - newConfig: 'New {name}', - editConfig: 'Edit {name}', - hasItemDrop: 'Has item drop', - dragSort: 'Drag to reorder: {name}', - dragSortTitle: 'Drag to reorder', - languageCode: 'Code', - languageName: 'Language name', - enabled: 'Enabled', - defaultLanguage: 'Default language', - sortOrder: 'Sort order', - newLanguage: 'New language', - editLanguage: 'Edit language' - } - }, - config: { - pokemonTypes: 'Pokemon Types', - skills: 'Specialities', - environments: 'Ideal Habitats', - favoriteThings: 'Favourites / tags', - itemCategories: 'Item categories', - itemUsages: 'Item usages', - acquisitionMethods: 'Acquisition methods', - maps: 'Maps', - lifeTags: 'Life tags' - }, - appearance: { - time: 'Time', - weather: 'Weather', - rarity: 'Rarity', - map: 'Map', - maps: 'Maps', - morning: 'Morning', - noon: 'Noon', - evening: 'Evening', - night: 'Night', - sunny: 'Sunny', - cloudy: 'Cloudy', - rainy: 'Rainy', - stars: '{count} stars' - }, - history: { - title: 'Contribution records', - createdBy: 'Created by', - lastEdited: 'Last edited', - editHistory: 'Edit history', - before: 'Before', - after: 'After', - author: 'Author', - time: 'Time', - action: 'Action', - create: 'Create', - update: 'Edit', - delete: 'Delete', - empty: 'No edit history' - }, - discussion: { - title: 'Discussion', - count: '{count} comments', - comment: 'Comment', - commentPlaceholder: 'Write a comment...', - replyPlaceholder: 'Write a reply...', - postComment: 'Post comment', - postingComment: 'Posting comment', - reply: 'Reply', - postReply: 'Post reply', - postingReply: 'Posting reply', - cancelReply: 'Cancel reply', - deleteComment: 'Delete comment', - deleteConfirm: 'Delete this comment?', - deletedComment: 'Comment deleted', - commentRequired: 'Please enter a comment.', - commentFailed: 'Comment failed', - replyFailed: 'Reply failed', - deleteFailed: 'Delete failed', - loading: 'Loading discussion', - empty: 'No discussion yet', - emptyHint: 'Start a new discussion now.', - loginPrompt: 'Log in with a verified email to comment.', - verifyPrompt: 'Complete email verification to comment.', - byUnknown: 'Community member', - charactersLeft: '{count} characters left' - } - }, - 'zh-CN': { - common: { - add: '添加', - admin: '管理', - all: '全部', - back: '返回', - backToList: '返回列表', - cancel: '取消', - close: '关闭', - create: '创建', - delete: '删除', - edit: '编辑', - details: '详情', - filters: '筛选', - loading: '加载中', - name: '名称', - new: '新建', - none: '无', - save: '保存', - saving: '保存中', - search: '搜索', - select: '请选择', - selected: '已选', - system: '系统', - noRecords: '暂无记录', - fieldForLanguage: '{field}({language})', - searchOrSelect: '搜索或选择', - noMatches: '没有匹配项', - createNamed: '添加「{name}」', - creating: '添加中', - inDev: '开发中', - removeNamed: '移除{name}', - quantity: '数量', - required: '必填' - }, - nav: { - pokemon: 'Pokemon', - habitats: '栖息地', - items: '物品', - recipes: '材料单', - dish: '料理', - events: '活动', - actions: '动作', - dreamIsland: 'Dream Island', - clothes: '服装', - checklist: 'CheckList', - life: 'Life', - admin: '管理', - main: '主导航', - openMenu: '打开导航', - closeMenu: '关闭导航', - language: '语言', - login: '登录', - logout: '退出', - register: '注册' - }, - auth: { - email: '邮箱', - password: '密码', - displayName: '显示名', - loginTitle: '登录', - loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki', - loggingIn: '登录中', - loginFailed: '登录失败', - noAccount: '还没有账号?', - registerTitle: '注册', - registerSubtitle: '创建账号后需要完成邮箱验证', - registerFailed: '注册失败', - sending: '发送中', - sendVerification: '发送验证邮件', - hasAccount: '已有账号?', - verifyTitle: '邮箱验证', - verifySubtitle: '完成验证后即可登录', - verifyingEmail: '正在验证邮箱', - invalidVerification: '验证链接无效或已过期', - verifyFailed: '邮箱验证失败', - goLogin: '去登录' - }, - errors: { - requestFailed: '请求失败({status})', - operationFailed: '操作失败', - loadFailed: '加载失败', - addFailed: '添加失败', - saveFailed: '保存失败', - completeEmailVerification: '请先完成邮箱验证' - }, - pages: { - pokemon: { - title: 'Pokemon', - subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。', - detailKicker: 'Pokédex Detail', - editKicker: 'Pokédex Edit', - editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。', - editSections: 'Pokemon 编辑分区', - editTabBasic: '基础', - editTabAdvance: '进阶', - newTitle: '新增 Pokemon', - editTitle: '编辑 #{id} {name}', - fetchData: '获取数据', - fetchingData: '正在获取', - fetchIdentifier: '数据标识', - fetchIdentifierPlaceholder: 'bulbasaur 或 1', - fetchIdentifierRequired: '请输入 Pokemon 数据标识', - fetchFailed: 'Pokemon 数据获取失败', - fetchIdMismatch: '获取到的 Pokemon ID #{id} 与当前编辑内容不一致。', - fetchResults: 'Pokemon 数据结果', - fetchSearching: '正在搜索数据', - fetchNoMatches: '没有匹配的 Pokemon 数据', - fetchSearchFailed: 'Pokemon 数据搜索失败', - loadingList: '正在加载 Pokemon 列表', - loadingDetail: '正在加载 Pokemon 详情', - loadingEdit: '正在加载 Pokemon 编辑内容', - environmentPrefix: '喜欢的环境:{name}', - details: '介绍', - genus: '分类', - height: '身高', - heightInput: '身高(in)', - heightImperial: 'ft / in', - heightMetric: 'm', - feet: 'ft', - inches: 'in', - meters: 'm', - weight: '体重', - weightInput: '体重(lb)', - pounds: 'lb', - kilograms: 'kg', - measurements: '身高与体重', - types: '属性', - typeOne: '属性 1', - typeTwo: '属性 2', - typesAndStats: '属性与六维', - statsTitle: '六维', - stats: { - hp: 'HP', - attack: '攻击', - defense: '防御', - specialAttack: '特攻', - specialDefense: '特防', - speed: '速度' - }, - environment: '喜欢的环境', - skills: '特长', - skillMatchMode: '特长匹配方式', - any: '任意', - all: '全部', - favoriteThings: '喜欢的东西', - favoriteThingMatchMode: '喜欢的东西匹配方式', - skillDrops: '特长掉落物', - skillDrop: '{name}掉落物', - dropItem: '掉落物', - searchPokemon: '搜索 Pokemon', - relatedPokemon: '相关 Pokemon', - relatedHabitat: '相关 Pokemon 栖息地', - relatedItems: '关联物品', - relatedItemCategory: '关联物品分类', - habitats: '栖息地', - namePlaceholder: '名字', - searchTypes: '搜索属性', - searchEnvironment: '搜索喜欢的环境', - searchSkills: '搜索特长', - searchFavoriteThings: '搜索喜欢的东西', - searchItems: '搜索物品' - }, - habitats: { - title: '栖息地', - subtitle: '查看配方和可能出现的宝可梦。', - detailSubtitle: '栖息地详情', - editSubtitle: '维护栖息地配方和可能出现的 Pokemon。', - newTitle: '新增栖息地', - editTitle: '编辑 {name}', - fallbackName: '栖息地', - loadingList: '正在加载栖息地列表', - loadingDetail: '正在加载栖息地详情', - loadingEdit: '正在加载栖息地编辑内容', - recipe: '配方', - recipeList: '配方列表', - possiblePokemon: '可能出现的宝可梦', - addItem: '添加物品', - addPokemon: '添加 Pokemon', - maps: '地图', - searchMaps: '搜索地图' - }, - items: { - title: '物品', - subtitle: '按分类、用途、标签查看物品。', - detailKicker: 'Item Detail', - detailSubtitle: '物品详情', - editKicker: 'Item Edit', - editSubtitle: '维护物品分类、用途、入手方式、自定义和标签。', - newTitle: '新增物品', - editTitle: '编辑 {name}', - fallbackName: '物品', - loadingList: '正在加载列表', - loadingDetail: '正在加载物品详情', - loadingEdit: '正在加载物品编辑内容', - category: '分类', - usage: '用途', - tags: '标签', - acquisitionMethods: '入手方式', - customization: '自定义', - dyeable: '可染色', - dualDyeable: '可双区染色', - patternEditable: '可改花纹', - noRecipe: '无材料单', - recipeInfo: '材料单信息', - relatedRecipes: '相关材料单', - relatedHabitats: '相关栖息地', - pokemonDrops: 'Pokemon 掉落', - createRecipe: '创建材料单', - searchCategory: '搜索分类', - searchUsage: '搜索用途', - searchMethods: '搜索入手方式', - searchTags: '搜索标签' - }, - recipes: { - title: '材料单', - subtitle: '按分类、用途、标签查看材料单。', - detailKicker: 'Recipe Detail', - detailSubtitle: '材料单详情', - editKicker: 'Recipe Edit', - editSubtitle: '维护材料单结果物品、入手方式和需要材料。', - newTitle: '新增材料单', - editTitle: '编辑 {name}', - fallbackName: '材料单', - loadingList: '正在加载材料单列表', - loadingDetail: '正在加载材料单详情', - loadingEdit: '正在加载材料单编辑内容', - item: '物品', - materials: '需要材料', - addMaterial: '添加材料' - }, - comingSoon: { - status: '正在开发中', - heading: '这个 Wiki 分区正在准备中。', - previewLabel: '分区预览', - sections: { - dish: { - kicker: 'Dish', - title: '料理', - subtitle: '未来会用于整理料理和食物相关发现。', - body: '料理页面会围绕清晰浏览、来源记录和材料关联来设计。', - preview: { - one: '料理记录会优先呈现名称、效果和发现方式。', - two: '需要时会把材料关系连接回物品和材料单。', - three: '页面会先保持浏览友好,后续再自然承接社区编辑内容。' - } - }, - events: { - kicker: 'Events', - title: '活动', - subtitle: '季节活动和限时内容资料会在这里整理。', - body: '活动分区会在准备好后集中展示时间、奖励和参与信息。', - preview: { - one: '活动卡片会让日期和开放时间更容易浏览。', - two: '奖励与关联物品会靠近活动摘要展示。', - three: '活动结束后,历史记录也会保持可读。' - } - }, - actions: { - kicker: 'Actions', - title: '动作', - subtitle: '挥手、跳舞等游戏内快捷动作会记录在这里。', - body: '动作分区会作为游戏内表情、社交动作和快捷动作的快速参考。', - preview: { - one: '每个动作会用面向玩家的语言说明动作或快捷方式。', - two: '常见内容包括挥手、跳舞和其他社交动作。', - three: '后续可在数据模型准备好后补充解锁或使用条件。' - } - }, - dreamIsland: { - kicker: 'Dream Island', - title: 'Dream Island', - subtitle: 'Dream Island 相关资料正在整理。', - body: '这个区域未来会用更像目的地资料页的方式展示岛屿信息。', - preview: { - one: '岛屿记录会优先整理地点、开放状态和重要发现。', - two: '可关联的 Pokemon、物品或活动会从页面中连接出来。', - three: '目前先保持公开浏览入口,不额外增加管理流程。' - } - }, - clothes: { - kicker: 'Clothes', - title: '服装', - subtitle: '外观和服装资料正在准备。', - body: '服装页面会用于对比外观、入手方式和自定义信息。', - preview: { - one: '服装条目会优先整理展示名称和视觉分类。', - two: '入手方式与自定义信息会在资料可用后接入。', - three: '页面会保持服装资料清晰,不和普通物品列表混在一起。' - } - } - } - }, - checklist: { - title: '每日清单', - subtitle: '查看每天可以完成的事项。', - sectionTitle: '每日做什么', - empty: '暂无每日清单', - loading: '正在加载每日清单', - task: 'Task', - newTask: '新增 Task', - editTask: '编辑 Task' - }, - life: { - title: 'Life', - subtitle: '分享喜欢的心得、想法和社区发现。', - kicker: '社区动态', - composerTitle: '分享动态', - composerPrompt: '想分享什么?', - bodyLabel: '动态内容', - bodyPlaceholder: '分享一段想法、心得或发现……', - newPost: 'New Post', - tags: '标签', - allTags: '全部', - tagPlaceholder: '选择标签', - searchTags: '搜索标签', - search: '搜索动态', - searchPlaceholder: '搜索动态内容……', - clearSearch: '清除搜索', - searchEmpty: '没有匹配的动态', - searchEmptyHint: '换个关键词或清除搜索。', - comments: '评论', - commentsCount: '{count} 条评论', - comment: '评论', - hideComments: '收起评论', - react: '点赞', - reactions: '互动', - reactionsCount: '{count} 次互动', - reactionCountLabel: '{reaction}:{count}', - reactionLike: '喜欢', - reactionHelpful: '有帮助', - reactionFun: '有趣', - reactionThanks: '感谢', - chooseReaction: '选择互动', - reactionMenu: '互动菜单', - removeReaction: '取消互动', - reactionFailed: '互动失败', - commentPlaceholder: '写下评论……', - commentReplyPlaceholder: '写下回复……', - postComment: '发表评论', - postingComment: '评论中', - reply: '回复', - postReply: '发布回复', - postingReply: '回复中', - cancelReply: '取消回复', - noComments: '暂无评论', - deleteComment: '删除评论', - deleteCommentConfirm: '确认删除这条评论?', - commentDeleted: '评论已删除', - commentRequired: '请输入评论内容。', - commentFailed: '评论失败', - replyFailed: '回复失败', - deleteCommentFailed: '删除评论失败', - publish: '发布', - publishing: '发布中', - update: '更新', - updating: '更新中', - cancelEdit: '取消编辑', - empty: '暂无动态', - emptyHint: '已验证成员可以发布第一条 Life 动态。', - loading: '正在加载 Life 动态', - retryFeed: '重试加载', - loginPrompt: '使用已验证邮箱登录后即可发布。', - verifyPrompt: '完成邮箱验证后即可发布。', - editPost: '编辑动态', - deletePost: '删除动态', - saveEdit: '保存编辑', - postFailed: '发布失败', - saveFailed: '保存失败', - deleteFailed: '删除失败', - bodyRequired: '请输入动态内容。', - byUnknown: '社区成员', - edited: '已编辑', - deleteConfirm: '确认删除这条动态?', - charactersLeft: '还可以输入 {count} 个字符' - }, - admin: { - title: '管理', - subtitle: '维护系统配置,查看并删除 Wiki 数据记录。', - modules: '管理模块', - loading: '正在加载管理列表', - config: '系统配置', - configType: '系统配置类型', - checklist: 'CheckList', - pokemonList: 'Pokemon 列表', - itemList: '物品列表', - recipeList: '材料单列表', - habitatList: '栖息地列表', - languages: '语言', - newConfig: '新增{name}', - editConfig: '编辑{name}', - hasItemDrop: '有掉落物', - dragSort: '拖曳排序:{name}', - dragSortTitle: '拖曳排序', - languageCode: 'Code', - languageName: '语言名称', - enabled: '启用', - defaultLanguage: '默认语言', - sortOrder: '排序', - newLanguage: '新增语言', - editLanguage: '编辑语言' - } - }, - config: { - pokemonTypes: 'Pokemon 属性', - skills: '特长', - environments: '喜欢的环境', - favoriteThings: '喜欢的东西 / 标签', - itemCategories: '物品分类', - itemUsages: '物品用途', - acquisitionMethods: '入手方式', - maps: '地图', - lifeTags: 'Life 标签' - }, - appearance: { - time: '时段', - weather: '天气', - rarity: '稀有度', - map: '地图', - maps: '出现地图', - morning: '早晨', - noon: '中午', - evening: '傍晚', - night: '晚上', - sunny: '晴天', - cloudy: '阴天', - rainy: '雨天', - stars: '{count} 星' - }, - history: { - title: '贡献记录', - createdBy: '由谁创建', - lastEdited: '最后编辑', - editHistory: '编辑历史', - before: '修改前', - after: '修改后', - author: '作者', - time: '时间', - action: '动作', - create: '创建', - update: '编辑', - delete: '删除', - empty: '暂无编辑历史' - }, - discussion: { - title: '讨论', - count: '{count} 条评论', - comment: '评论', - commentPlaceholder: '写下评论……', - replyPlaceholder: '写下回复……', - postComment: '发表评论', - postingComment: '评论中', - reply: '回复', - postReply: '发布回复', - postingReply: '回复中', - cancelReply: '取消回复', - deleteComment: '删除评论', - deleteConfirm: '确认删除这条评论?', - deletedComment: '评论已删除', - commentRequired: '请输入评论内容。', - commentFailed: '评论失败', - replyFailed: '回复失败', - deleteFailed: '删除失败', - loading: '正在加载讨论', - empty: '暂无讨论', - emptyHint: '现在发起新的讨论。', - loginPrompt: '使用已验证邮箱登录后即可评论。', - verifyPrompt: '完成邮箱验证后即可评论。', - byUnknown: '社区成员', - charactersLeft: '还可以输入 {count} 个字符' - } - } +const messages = systemWordingMessages as unknown as Record; +const loadedWordingLocales = new Set(); +const pendingWordingLoads = new Map>(); + +type SystemWordingsResponse = { + locale: string; + messages: SystemWordingTree; }; export type MessageKey = keyof typeof messages.en; @@ -978,6 +42,67 @@ export function getCurrentLocale(): string { return globalLocaleRef().value || defaultLocale; } +function isMessageTree(value: SystemWordingTree[string] | undefined): value is SystemWordingTree { + return typeof value === 'object' && value !== null; +} + +function mergeMessageTrees(...trees: Array): SystemWordingTree { + const merged: SystemWordingTree = {}; + + for (const tree of trees) { + if (!tree) { + continue; + } + + for (const [key, value] of Object.entries(tree)) { + const current = merged[key]; + merged[key] = isMessageTree(value) && isMessageTree(current) ? mergeMessageTrees(current, value) : value; + } + } + + return merged; +} + +function builtInMessagesFor(locale: string): SystemWordingTree { + return mergeMessageTrees(messages[defaultLocale], messages[locale]); +} + +export async function loadSystemWordings(locale = getCurrentLocale(), force = false): Promise { + const targetLocale = locale || defaultLocale; + if (!force && loadedWordingLocales.has(targetLocale)) { + return; + } + + const pendingLoad = pendingWordingLoads.get(targetLocale); + if (pendingLoad) { + await pendingLoad; + return; + } + + const loadPromise = (async () => { + try { + const response = await fetch(`${apiBaseUrl}/api/system-wordings?locale=${encodeURIComponent(targetLocale)}`); + if (!response.ok) { + throw new Error(`System wordings failed (${response.status})`); + } + + const data = (await response.json()) as SystemWordingsResponse; + i18n.global.setLocaleMessage( + targetLocale, + mergeMessageTrees(messages[defaultLocale], messages[targetLocale], data.messages) as never + ); + loadedWordingLocales.add(targetLocale); + } catch { + i18n.global.setLocaleMessage(targetLocale, builtInMessagesFor(targetLocale) as never); + } finally { + pendingWordingLoads.delete(targetLocale); + } + })(); + + pendingWordingLoads.set(targetLocale, loadPromise); + await loadPromise; +} + export function setCurrentLocale(locale: string): void { const nextLocale = locale || defaultLocale; globalLocaleRef().value = nextLocale; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 14f3d5d..a5a850f 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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; @@ -508,6 +523,10 @@ export const api = { sendJson(`/api/admin/languages/${code}`, 'PUT', payload), reorderLanguages: (codes: string[]) => sendJson('/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(`/api/admin/system-wordings${buildQuery(params)}`), + updateSystemWording: (key: string, payload: { locale: string; value: string }) => + sendJson(`/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 }), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 8badca3..74befc4 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -2409,6 +2409,50 @@ button:disabled, font-weight: 850; } +.system-wording-toolbar { + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + align-items: end; +} + +.system-wording-toolbar__check { + min-height: 44px; + display: flex; + align-items: center; +} + +.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; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index fecc84c..74dfa90 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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 = { config: iconAdmin, languages: iconTranslate, + wordings: iconTranslate, checklist: iconChecklist, pokemon: iconPokemon, items: iconItem, @@ -58,6 +61,7 @@ const { locale, t } = useI18n(); const tabs = computed>(() => [ { 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([]); const itemRows = ref([]); const recipeRows = ref([]); const habitatRows = ref([]); +const wordingRows = ref([]); const currentUser = ref(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(''); +const wordingMissingOnly = ref(false); const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]); const configTabs = computed(() => configTypes.value.map((item) => ({ value: item.key, label: item.label }))); @@ -140,6 +151,24 @@ 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 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 +226,10 @@ function resetLanguageForm() { editingLanguageCode.value = ''; } +function resetWordingForm() { + wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] }; +} + function openNewConfig() { resetConfigForm(); configModalOpen.value = true; @@ -237,6 +270,11 @@ function closeLanguageModal() { resetLanguageForm(); } +function closeWordingModal() { + wordingModalOpen.value = false; + resetWordingForm(); +} + function editLanguage(item: Language) { editingLanguageCode.value = item.code; languageForm.value = { @@ -249,6 +287,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 +505,7 @@ async function saveLanguage() { ? await api.updateLanguage(editingLanguageCode.value, payload) : await api.createLanguage(payload); closeLanguageModal(); + await loadSystemWordings(getCurrentLocale(), true); setCurrentLocale(getCurrentLocale()); }); } @@ -484,6 +534,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 +568,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 +816,64 @@ onMounted(() => {

{{ t('common.noRecords') }}

+
+
+

{{ t('pages.admin.wordings') }}

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
    +
  • + + {{ item.key }} + + {{ item.module }} + {{ t(`pages.admin.surface${item.surface.charAt(0).toUpperCase()}${item.surface.slice(1)}`) }} + {{ t('pages.admin.missingTranslation') }} + + {{ item.value || item.defaultValue }} + + + + +
  • +
+

{{ t('common.noRecords') }}

+
+

{{ t('pages.admin.pokemonList') }}

{ + + + + + +
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index eab4502..5c6c3fe 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -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"] } diff --git a/package.json b/package.json index 08ab434..b27d6f3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/system-wordings.ts b/system-wordings.ts new file mode 100644 index 0000000..eabd95f --- /dev/null +++ b/system-wordings.ts @@ -0,0 +1,1247 @@ +export type SystemWordingLeaf = string; +export type SystemWordingTree = { [key: string]: SystemWordingLeaf | SystemWordingTree }; +export type SystemWordingMessages = Record; + +export const defaultLocale = 'en'; + +export const systemWordingMessages = { + en: { + common: { + add: 'Add', + admin: 'Admin', + all: 'All', + back: 'Back', + backToList: 'Back to list', + cancel: 'Cancel', + close: 'Close', + create: 'Create', + delete: 'Delete', + edit: 'Edit', + details: 'Details', + filters: 'Filters', + loading: 'Loading', + name: 'Name', + new: 'New', + none: 'None', + save: 'Save', + saving: 'Saving', + search: 'Search', + select: 'Select', + selected: 'Selected', + system: 'System', + noRecords: 'No records', + fieldForLanguage: '{field} ({language})', + searchOrSelect: 'Search or select', + noMatches: 'No matches', + createNamed: 'Add "{name}"', + creating: 'Adding', + inDev: 'In-Dev', + removeNamed: 'Remove {name}', + quantity: 'Quantity', + required: 'Required' + }, + nav: { + pokemon: 'Pokemon', + habitats: 'Habitats', + items: 'Items', + recipes: 'Recipes', + dish: 'Dish', + events: 'Events', + actions: 'Actions', + dreamIsland: 'Dream Island', + clothes: 'Clothes', + checklist: 'CheckList', + life: 'Life', + admin: 'Admin', + main: 'Main navigation', + openMenu: 'Open navigation', + closeMenu: 'Close navigation', + language: 'Language', + login: 'Log in', + logout: 'Log out', + register: 'Register' + }, + auth: { + email: 'Email', + password: 'Password', + displayName: 'Display name', + loginTitle: 'Log in', + loginSubtitle: 'Use a verified email to enter Pokopia Wiki.', + loggingIn: 'Logging in', + loginFailed: 'Login failed', + noAccount: 'No account yet?', + registerTitle: 'Register', + registerSubtitle: 'Verify your email after creating an account.', + registerFailed: 'Registration failed', + sending: 'Sending', + sendVerification: 'Send verification email', + hasAccount: 'Already have an account?', + verifyTitle: 'Email verification', + verifySubtitle: 'You can log in after verification is complete.', + verifyingEmail: 'Verifying email', + invalidVerification: 'The verification link is invalid or expired.', + verifyFailed: 'Email verification failed', + goLogin: 'Go to login' + }, + errors: { + requestFailed: 'Request failed ({status})', + operationFailed: 'Operation failed', + loadFailed: 'Load failed', + addFailed: 'Add failed', + saveFailed: 'Save failed', + completeEmailVerification: 'Please complete email verification first.' + }, + pages: { + pokemon: { + title: 'Pokemon', + subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.', + detailKicker: 'Pokédex Detail', + editKicker: 'Pokédex Edit', + editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.', + editSections: 'Pokemon edit sections', + editTabBasic: 'Basic', + editTabAdvance: 'Advance', + newTitle: 'New Pokemon', + editTitle: 'Edit #{id} {name}', + fetchData: 'Fetch data', + fetchingData: 'Fetching', + fetchIdentifier: 'Data identifier', + fetchIdentifierPlaceholder: 'bulbasaur or 1', + fetchIdentifierRequired: 'Enter a Pokemon identifier', + fetchFailed: 'Pokemon data fetch failed', + fetchIdMismatch: 'Fetched Pokemon ID #{id} does not match this editor.', + fetchResults: 'Pokemon data results', + fetchSearching: 'Searching data', + fetchNoMatches: 'No matching Pokemon data', + fetchSearchFailed: 'Pokemon data search failed', + loadingList: 'Loading Pokemon list', + loadingDetail: 'Loading Pokemon detail', + loadingEdit: 'Loading Pokemon editor', + environmentPrefix: 'Ideal Habitat: {name}', + details: 'Details', + genus: 'Genus', + height: 'Height', + heightInput: 'Height (in)', + heightImperial: 'ft / in', + heightMetric: 'm', + feet: 'ft', + inches: 'in', + meters: 'm', + weight: 'Weight', + weightInput: 'Weight (lb)', + pounds: 'lb', + kilograms: 'kg', + measurements: 'Height & Weight', + types: 'Types', + typeOne: 'Type 1', + typeTwo: 'Type 2', + typesAndStats: 'Types & Base stats', + statsTitle: 'Base stats', + stats: { + hp: 'HP', + attack: 'Attack', + defense: 'Defense', + specialAttack: 'Special Attack', + specialDefense: 'Special Defense', + speed: 'Speed' + }, + environment: 'Ideal Habitat', + skills: 'Specialities', + skillMatchMode: 'Speciality match mode', + any: 'Any', + all: 'All', + favoriteThings: 'Favourites', + favoriteThingMatchMode: 'Favourites match mode', + skillDrops: 'Speciality drops', + skillDrop: '{name} drop', + dropItem: 'Drop item', + searchPokemon: 'Search Pokemon', + relatedPokemon: 'Related Pokemon', + relatedHabitat: 'Related Pokemon habitat', + relatedItems: 'Related items', + relatedItemCategory: 'Related item category', + habitats: 'Habitats', + namePlaceholder: 'Name', + searchTypes: 'Search types', + searchEnvironment: 'Search ideal habitats', + searchSkills: 'Search specialities', + searchFavoriteThings: 'Search favourites', + searchItems: 'Search items' + }, + habitats: { + title: 'Habitats', + subtitle: 'View recipes and Pokemon that may appear.', + detailSubtitle: 'Habitat detail', + editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.', + newTitle: 'New habitat', + editTitle: 'Edit {name}', + fallbackName: 'Habitat', + loadingList: 'Loading habitat list', + loadingDetail: 'Loading habitat detail', + loadingEdit: 'Loading habitat editor', + recipe: 'Recipe', + recipeList: 'Recipe list', + possiblePokemon: 'Possible Pokemon', + addItem: 'Add item', + addPokemon: 'Add Pokemon', + maps: 'Maps', + searchMaps: 'Search maps' + }, + items: { + title: 'Items', + subtitle: 'Browse items by category, usage, and tags.', + detailKicker: 'Item Detail', + detailSubtitle: 'Item detail', + editKicker: 'Item Edit', + editSubtitle: 'Maintain item category, usage, acquisition methods, customization, and tags.', + newTitle: 'New item', + editTitle: 'Edit {name}', + fallbackName: 'Item', + loadingList: 'Loading item list', + loadingDetail: 'Loading item detail', + loadingEdit: 'Loading item editor', + category: 'Category', + usage: 'Usage', + tags: 'Tags', + acquisitionMethods: 'Acquisition methods', + customization: 'Customization', + dyeable: 'Dyeable', + dualDyeable: 'Dual dyeable', + patternEditable: 'Pattern editable', + noRecipe: 'No recipe', + recipeInfo: 'Recipe info', + relatedRecipes: 'Related recipes', + relatedHabitats: 'Related habitats', + pokemonDrops: 'Pokemon drops', + createRecipe: 'Create recipe', + searchCategory: 'Search categories', + searchUsage: 'Search usages', + searchMethods: 'Search acquisition methods', + searchTags: 'Search tags' + }, + recipes: { + title: 'Recipes', + subtitle: 'Browse recipes by category, usage, and tags.', + detailKicker: 'Recipe Detail', + detailSubtitle: 'Recipe detail', + editKicker: 'Recipe Edit', + editSubtitle: 'Maintain result item, acquisition methods, and materials.', + newTitle: 'New recipe', + editTitle: 'Edit {name}', + fallbackName: 'Recipe', + loadingList: 'Loading recipe list', + loadingDetail: 'Loading recipe detail', + loadingEdit: 'Loading recipe editor', + item: 'Item', + materials: 'Materials', + addMaterial: 'Add material' + }, + comingSoon: { + status: 'In development', + heading: 'This wiki section is being prepared.', + previewLabel: 'Section preview', + sections: { + dish: { + kicker: 'Dish', + title: 'Dish', + subtitle: 'A future home for cooked dishes and food discoveries.', + body: 'Dish pages are being shaped for clear browsing, source notes, and useful ingredient links.', + preview: { + one: 'Dish records will focus on names, effects, and discovery context.', + two: 'Ingredient relationships will connect back to items and recipes where useful.', + three: 'The page will stay browse-first so community edits can grow naturally.' + } + }, + events: { + kicker: 'Events', + title: 'Events', + subtitle: 'Seasonal and limited-time game activity records are coming later.', + body: 'Events will collect timing, rewards, and participation details once the section is ready.', + preview: { + one: 'Event cards will make dates and active windows easy to scan.', + two: 'Rewards and related items will sit close to the event summary.', + three: 'Archived activities will remain readable after they end.' + } + }, + actions: { + kicker: 'Actions', + title: 'Actions', + subtitle: 'Game shortcut actions such as waving and dancing will be documented here.', + body: 'Actions are being prepared as a quick reference for expressive in-game gestures and shortcuts.', + preview: { + one: 'Each action will describe the gesture or shortcut in player-facing language.', + two: 'Common examples include waving, dancing, and other social actions.', + three: 'Related unlock or usage details can be linked when the data model is ready.' + } + }, + dreamIsland: { + kicker: 'Dream Island', + title: 'Dream Island', + subtitle: 'Dream Island information is being organized for future browsing.', + body: 'This area will present island details with a calm, destination-style layout when content is ready.', + preview: { + one: 'Island notes will prioritize location, availability, and notable discoveries.', + two: 'Related Pokemon, items, or activities can be connected from the page.', + three: 'The layout will support browsing without adding another management flow yet.' + } + }, + clothes: { + kicker: 'Clothes', + title: 'Clothes', + subtitle: 'Outfit and clothing references are being prepared.', + body: 'Clothes pages will make it easy to compare appearance, acquisition, and customization details.', + preview: { + one: 'Clothing entries will focus on display names and visual categories.', + two: 'Acquisition and customization details can be connected when available.', + three: 'The page will keep item-like details readable without mixing them into the item list.' + } + } + } + }, + checklist: { + title: 'Daily checklist', + subtitle: 'See what can be completed each day.', + sectionTitle: 'Daily tasks', + empty: 'No daily checklist', + loading: 'Loading daily checklist', + task: 'Task', + newTask: 'New task', + editTask: 'Edit task' + }, + life: { + title: 'Life', + subtitle: 'Share favourite thoughts, tips, and community finds.', + kicker: 'Community Feed', + composerTitle: 'Share something', + composerPrompt: 'What would you like to share?', + bodyLabel: 'Post', + bodyPlaceholder: 'Share a thought, tip, or discovery...', + newPost: 'New Post', + tags: 'Tags', + allTags: 'All', + tagPlaceholder: 'Select tags', + searchTags: 'Search tags', + search: 'Search Life', + searchPlaceholder: 'Search post content...', + clearSearch: 'Clear search', + searchEmpty: 'No posts match your search', + searchEmptyHint: 'Try another keyword or clear the search.', + comments: 'Comments', + commentsCount: '{count} comments', + comment: 'Comment', + hideComments: 'Hide comments', + react: 'Like', + reactions: 'Reactions', + reactionsCount: '{count} reactions', + reactionCountLabel: '{reaction}: {count}', + reactionLike: 'Like', + reactionHelpful: 'Helpful', + reactionFun: 'Fun', + reactionThanks: 'Thanks', + chooseReaction: 'Choose reaction', + reactionMenu: 'Reaction menu', + removeReaction: 'Remove reaction', + reactionFailed: 'Reaction failed', + commentPlaceholder: 'Write a comment...', + commentReplyPlaceholder: 'Write a reply...', + postComment: 'Post comment', + postingComment: 'Posting comment', + reply: 'Reply', + postReply: 'Post reply', + postingReply: 'Posting reply', + cancelReply: 'Cancel reply', + noComments: 'No comments yet', + deleteComment: 'Delete comment', + deleteCommentConfirm: 'Delete this comment?', + commentDeleted: 'Comment deleted', + commentRequired: 'Please enter a comment.', + commentFailed: 'Comment failed', + replyFailed: 'Reply failed', + deleteCommentFailed: 'Delete comment failed', + publish: 'Post', + publishing: 'Posting', + update: 'Update', + updating: 'Updating', + cancelEdit: 'Cancel edit', + empty: 'No posts yet', + emptyHint: 'Verified members can start the first Life post.', + loading: 'Loading Life feed', + retryFeed: 'Retry loading', + loginPrompt: 'Log in with a verified email to post.', + verifyPrompt: 'Complete email verification to post.', + editPost: 'Edit post', + deletePost: 'Delete post', + saveEdit: 'Save edit', + postFailed: 'Post failed', + saveFailed: 'Save failed', + deleteFailed: 'Delete failed', + bodyRequired: 'Please enter a post.', + byUnknown: 'Community member', + edited: 'Edited', + deleteConfirm: 'Delete this post?', + charactersLeft: '{count} characters left' + }, + admin: { + title: 'Admin', + subtitle: 'Maintain system configuration and manage Wiki records.', + modules: 'Admin modules', + loading: 'Loading admin list', + config: 'System config', + configType: 'System config type', + checklist: 'CheckList', + pokemonList: 'Pokemon list', + itemList: 'Item list', + recipeList: 'Recipe list', + habitatList: 'Habitat list', + languages: 'Languages', + newConfig: 'New {name}', + editConfig: 'Edit {name}', + hasItemDrop: 'Has item drop', + dragSort: 'Drag to reorder: {name}', + dragSortTitle: 'Drag to reorder', + languageCode: 'Code', + languageName: 'Language name', + enabled: 'Enabled', + defaultLanguage: 'Default language', + sortOrder: 'Sort order', + newLanguage: 'New language', + editLanguage: 'Edit language', + wordings: 'System wordings', + wordingLocale: 'Locale', + wordingModule: 'Module', + wordingSurface: 'Surface', + wordingMissingOnly: 'Missing only', + wordingKey: 'Key', + wordingValue: 'Wording', + defaultValue: 'Default wording', + placeholders: 'Placeholders', + missingTranslation: 'Missing translation', + allModules: 'All modules', + allSurfaces: 'All surfaces', + surfaceFrontend: 'Frontend', + surfaceBackend: 'Backend', + surfaceEmail: 'Email', + editWording: 'Edit wording' + } + }, + config: { + pokemonTypes: 'Pokemon Types', + skills: 'Specialities', + environments: 'Ideal Habitats', + favoriteThings: 'Favourites / tags', + itemCategories: 'Item categories', + itemUsages: 'Item usages', + acquisitionMethods: 'Acquisition methods', + maps: 'Maps', + lifeTags: 'Life tags' + }, + appearance: { + time: 'Time', + weather: 'Weather', + rarity: 'Rarity', + map: 'Map', + maps: 'Maps', + morning: 'Morning', + noon: 'Noon', + evening: 'Evening', + night: 'Night', + sunny: 'Sunny', + cloudy: 'Cloudy', + rainy: 'Rainy', + stars: '{count} stars' + }, + history: { + title: 'Contribution records', + createdBy: 'Created by', + lastEdited: 'Last edited', + editHistory: 'Edit history', + before: 'Before', + after: 'After', + author: 'Author', + time: 'Time', + action: 'Action', + create: 'Create', + update: 'Edit', + delete: 'Delete', + empty: 'No edit history' + }, + discussion: { + title: 'Discussion', + count: '{count} comments', + comment: 'Comment', + commentPlaceholder: 'Write a comment...', + replyPlaceholder: 'Write a reply...', + postComment: 'Post comment', + postingComment: 'Posting comment', + reply: 'Reply', + postReply: 'Post reply', + postingReply: 'Posting reply', + cancelReply: 'Cancel reply', + deleteComment: 'Delete comment', + deleteConfirm: 'Delete this comment?', + deletedComment: 'Comment deleted', + commentRequired: 'Please enter a comment.', + commentFailed: 'Comment failed', + replyFailed: 'Reply failed', + deleteFailed: 'Delete failed', + loading: 'Loading discussion', + empty: 'No discussion yet', + emptyHint: 'Start a new discussion now.', + loginPrompt: 'Log in with a verified email to comment.', + verifyPrompt: 'Complete email verification to comment.', + byUnknown: 'Community member', + charactersLeft: '{count} characters left' + }, + server: { + errors: { + 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', + notFound: 'Not found' + }, + auth: { + 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' + }, + validation: { + nameRequired: 'Name is required', + recordMissing: 'Record does not exist', + languageCodeInvalid: 'Language code is invalid', + languageNameRequired: 'Language name is required', + defaultLanguageMustBeEnglish: 'Default language must be English', + defaultLanguageMustBeEnabled: 'Default language must be enabled', + languageNotFound: 'Language not found', + defaultLanguageRequired: 'A default language is required', + defaultLanguageCannotBeDeleted: 'Default language cannot be deleted', + selectLanguage: 'Please select a language', + languageDoesNotExist: 'Language does not exist', + pokemonIdentifierRequired: 'Pokemon identifier is required', + pokemonTypeDataUnavailable: 'Pokemon type data is unavailable', + pokemonDataNotFound: 'Pokemon data was not found', + taskRequired: 'Please enter a task', + selectTask: 'Please select a task', + taskDoesNotExist: 'Task does not exist', + postRequired: 'Please enter a post', + postTooLong: 'Post is too long', + commentRequired: 'Please enter a comment', + commentTooLong: 'Comment is too long', + reactionInvalid: 'Reaction is invalid', + cursorInvalid: 'Cursor is invalid', + tagInvalid: 'Tag is invalid', + entityTypeInvalid: 'Entity type is invalid', + recordInvalid: 'Record is invalid', + commentInvalid: 'Comment is invalid', + selectRecord: 'Please select a record', + typeMin: 'Choose at least 1 type', + typeMax: 'Choose at most 2 types', + skillMax: 'Choose at most 2 specialities', + favoriteMax: 'Choose at most 6 favourites', + dropItemSelectedSkill: 'Drop items must be linked to selected specialities', + pokemonIdRequired: 'Pokemon ID is required', + pokemonNameRequired: 'Pokemon name is required', + heightNonNegative: 'Height must be a non-negative number', + weightNonNegative: 'Weight must be a non-negative number', + environmentRequired: 'Ideal Habitat is required', + skillNoDrop: 'This speciality cannot have a drop item', + habitatNameRequired: 'Habitat name is required', + usageRequired: 'Usage is required', + itemNameRequired: 'Item name is required', + categoryRequired: 'Category is required', + recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free', + itemRequired: 'Item is required', + recipeFreeItem: 'This item is marked as recipe-free', + statNonNegative: 'Base stat must be a non-negative integer', + pokemonDataFileEmpty: 'Pokemon data file is empty', + pokemonDataFileUnavailable: 'Pokemon data file is unavailable' + }, + wordings: { + keyNotFound: 'System wording key was not found', + localeRequired: 'Locale is required', + valueRequired: 'Wording is required', + placeholderMismatch: 'Placeholders must match the default wording' + } + }, + email: { + auth: { + verificationSubject: 'Verify your Pokopia Wiki email', + verificationHtml: + '

Open the link below to verify your email:

Verify email

The link expires in {hours} hours.

', + verificationText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.' + } + }, + }, + 'zh-CN': { + common: { + add: '添加', + admin: '管理', + all: '全部', + back: '返回', + backToList: '返回列表', + cancel: '取消', + close: '关闭', + create: '创建', + delete: '删除', + edit: '编辑', + details: '详情', + filters: '筛选', + loading: '加载中', + name: '名称', + new: '新建', + none: '无', + save: '保存', + saving: '保存中', + search: '搜索', + select: '请选择', + selected: '已选', + system: '系统', + noRecords: '暂无记录', + fieldForLanguage: '{field}({language})', + searchOrSelect: '搜索或选择', + noMatches: '没有匹配项', + createNamed: '添加「{name}」', + creating: '添加中', + inDev: '开发中', + removeNamed: '移除{name}', + quantity: '数量', + required: '必填' + }, + nav: { + pokemon: 'Pokemon', + habitats: '栖息地', + items: '物品', + recipes: '材料单', + dish: '料理', + events: '活动', + actions: '动作', + dreamIsland: 'Dream Island', + clothes: '服装', + checklist: 'CheckList', + life: 'Life', + admin: '管理', + main: '主导航', + openMenu: '打开导航', + closeMenu: '关闭导航', + language: '语言', + login: '登录', + logout: '退出', + register: '注册' + }, + auth: { + email: '邮箱', + password: '密码', + displayName: '显示名', + loginTitle: '登录', + loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki', + loggingIn: '登录中', + loginFailed: '登录失败', + noAccount: '还没有账号?', + registerTitle: '注册', + registerSubtitle: '创建账号后需要完成邮箱验证', + registerFailed: '注册失败', + sending: '发送中', + sendVerification: '发送验证邮件', + hasAccount: '已有账号?', + verifyTitle: '邮箱验证', + verifySubtitle: '完成验证后即可登录', + verifyingEmail: '正在验证邮箱', + invalidVerification: '验证链接无效或已过期', + verifyFailed: '邮箱验证失败', + goLogin: '去登录' + }, + errors: { + requestFailed: '请求失败({status})', + operationFailed: '操作失败', + loadFailed: '加载失败', + addFailed: '添加失败', + saveFailed: '保存失败', + completeEmailVerification: '请先完成邮箱验证' + }, + pages: { + pokemon: { + title: 'Pokemon', + subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。', + detailKicker: 'Pokédex Detail', + editKicker: 'Pokédex Edit', + editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。', + editSections: 'Pokemon 编辑分区', + editTabBasic: '基础', + editTabAdvance: '进阶', + newTitle: '新增 Pokemon', + editTitle: '编辑 #{id} {name}', + fetchData: '获取数据', + fetchingData: '正在获取', + fetchIdentifier: '数据标识', + fetchIdentifierPlaceholder: 'bulbasaur 或 1', + fetchIdentifierRequired: '请输入 Pokemon 数据标识', + fetchFailed: 'Pokemon 数据获取失败', + fetchIdMismatch: '获取到的 Pokemon ID #{id} 与当前编辑内容不一致。', + fetchResults: 'Pokemon 数据结果', + fetchSearching: '正在搜索数据', + fetchNoMatches: '没有匹配的 Pokemon 数据', + fetchSearchFailed: 'Pokemon 数据搜索失败', + loadingList: '正在加载 Pokemon 列表', + loadingDetail: '正在加载 Pokemon 详情', + loadingEdit: '正在加载 Pokemon 编辑内容', + environmentPrefix: '喜欢的环境:{name}', + details: '介绍', + genus: '分类', + height: '身高', + heightInput: '身高(in)', + heightImperial: 'ft / in', + heightMetric: 'm', + feet: 'ft', + inches: 'in', + meters: 'm', + weight: '体重', + weightInput: '体重(lb)', + pounds: 'lb', + kilograms: 'kg', + measurements: '身高与体重', + types: '属性', + typeOne: '属性 1', + typeTwo: '属性 2', + typesAndStats: '属性与六维', + statsTitle: '六维', + stats: { + hp: 'HP', + attack: '攻击', + defense: '防御', + specialAttack: '特攻', + specialDefense: '特防', + speed: '速度' + }, + environment: '喜欢的环境', + skills: '特长', + skillMatchMode: '特长匹配方式', + any: '任意', + all: '全部', + favoriteThings: '喜欢的东西', + favoriteThingMatchMode: '喜欢的东西匹配方式', + skillDrops: '特长掉落物', + skillDrop: '{name}掉落物', + dropItem: '掉落物', + searchPokemon: '搜索 Pokemon', + relatedPokemon: '相关 Pokemon', + relatedHabitat: '相关 Pokemon 栖息地', + relatedItems: '关联物品', + relatedItemCategory: '关联物品分类', + habitats: '栖息地', + namePlaceholder: '名字', + searchTypes: '搜索属性', + searchEnvironment: '搜索喜欢的环境', + searchSkills: '搜索特长', + searchFavoriteThings: '搜索喜欢的东西', + searchItems: '搜索物品' + }, + habitats: { + title: '栖息地', + subtitle: '查看配方和可能出现的宝可梦。', + detailSubtitle: '栖息地详情', + editSubtitle: '维护栖息地配方和可能出现的 Pokemon。', + newTitle: '新增栖息地', + editTitle: '编辑 {name}', + fallbackName: '栖息地', + loadingList: '正在加载栖息地列表', + loadingDetail: '正在加载栖息地详情', + loadingEdit: '正在加载栖息地编辑内容', + recipe: '配方', + recipeList: '配方列表', + possiblePokemon: '可能出现的宝可梦', + addItem: '添加物品', + addPokemon: '添加 Pokemon', + maps: '地图', + searchMaps: '搜索地图' + }, + items: { + title: '物品', + subtitle: '按分类、用途、标签查看物品。', + detailKicker: 'Item Detail', + detailSubtitle: '物品详情', + editKicker: 'Item Edit', + editSubtitle: '维护物品分类、用途、入手方式、自定义和标签。', + newTitle: '新增物品', + editTitle: '编辑 {name}', + fallbackName: '物品', + loadingList: '正在加载列表', + loadingDetail: '正在加载物品详情', + loadingEdit: '正在加载物品编辑内容', + category: '分类', + usage: '用途', + tags: '标签', + acquisitionMethods: '入手方式', + customization: '自定义', + dyeable: '可染色', + dualDyeable: '可双区染色', + patternEditable: '可改花纹', + noRecipe: '无材料单', + recipeInfo: '材料单信息', + relatedRecipes: '相关材料单', + relatedHabitats: '相关栖息地', + pokemonDrops: 'Pokemon 掉落', + createRecipe: '创建材料单', + searchCategory: '搜索分类', + searchUsage: '搜索用途', + searchMethods: '搜索入手方式', + searchTags: '搜索标签' + }, + recipes: { + title: '材料单', + subtitle: '按分类、用途、标签查看材料单。', + detailKicker: 'Recipe Detail', + detailSubtitle: '材料单详情', + editKicker: 'Recipe Edit', + editSubtitle: '维护材料单结果物品、入手方式和需要材料。', + newTitle: '新增材料单', + editTitle: '编辑 {name}', + fallbackName: '材料单', + loadingList: '正在加载材料单列表', + loadingDetail: '正在加载材料单详情', + loadingEdit: '正在加载材料单编辑内容', + item: '物品', + materials: '需要材料', + addMaterial: '添加材料' + }, + comingSoon: { + status: '正在开发中', + heading: '这个 Wiki 分区正在准备中。', + previewLabel: '分区预览', + sections: { + dish: { + kicker: 'Dish', + title: '料理', + subtitle: '未来会用于整理料理和食物相关发现。', + body: '料理页面会围绕清晰浏览、来源记录和材料关联来设计。', + preview: { + one: '料理记录会优先呈现名称、效果和发现方式。', + two: '需要时会把材料关系连接回物品和材料单。', + three: '页面会先保持浏览友好,后续再自然承接社区编辑内容。' + } + }, + events: { + kicker: 'Events', + title: '活动', + subtitle: '季节活动和限时内容资料会在这里整理。', + body: '活动分区会在准备好后集中展示时间、奖励和参与信息。', + preview: { + one: '活动卡片会让日期和开放时间更容易浏览。', + two: '奖励与关联物品会靠近活动摘要展示。', + three: '活动结束后,历史记录也会保持可读。' + } + }, + actions: { + kicker: 'Actions', + title: '动作', + subtitle: '挥手、跳舞等游戏内快捷动作会记录在这里。', + body: '动作分区会作为游戏内表情、社交动作和快捷动作的快速参考。', + preview: { + one: '每个动作会用面向玩家的语言说明动作或快捷方式。', + two: '常见内容包括挥手、跳舞和其他社交动作。', + three: '后续可在数据模型准备好后补充解锁或使用条件。' + } + }, + dreamIsland: { + kicker: 'Dream Island', + title: 'Dream Island', + subtitle: 'Dream Island 相关资料正在整理。', + body: '这个区域未来会用更像目的地资料页的方式展示岛屿信息。', + preview: { + one: '岛屿记录会优先整理地点、开放状态和重要发现。', + two: '可关联的 Pokemon、物品或活动会从页面中连接出来。', + three: '目前先保持公开浏览入口,不额外增加管理流程。' + } + }, + clothes: { + kicker: 'Clothes', + title: '服装', + subtitle: '外观和服装资料正在准备。', + body: '服装页面会用于对比外观、入手方式和自定义信息。', + preview: { + one: '服装条目会优先整理展示名称和视觉分类。', + two: '入手方式与自定义信息会在资料可用后接入。', + three: '页面会保持服装资料清晰,不和普通物品列表混在一起。' + } + } + } + }, + checklist: { + title: '每日清单', + subtitle: '查看每天可以完成的事项。', + sectionTitle: '每日做什么', + empty: '暂无每日清单', + loading: '正在加载每日清单', + task: 'Task', + newTask: '新增 Task', + editTask: '编辑 Task' + }, + life: { + title: 'Life', + subtitle: '分享喜欢的心得、想法和社区发现。', + kicker: '社区动态', + composerTitle: '分享动态', + composerPrompt: '想分享什么?', + bodyLabel: '动态内容', + bodyPlaceholder: '分享一段想法、心得或发现……', + newPost: 'New Post', + tags: '标签', + allTags: '全部', + tagPlaceholder: '选择标签', + searchTags: '搜索标签', + search: '搜索动态', + searchPlaceholder: '搜索动态内容……', + clearSearch: '清除搜索', + searchEmpty: '没有匹配的动态', + searchEmptyHint: '换个关键词或清除搜索。', + comments: '评论', + commentsCount: '{count} 条评论', + comment: '评论', + hideComments: '收起评论', + react: '点赞', + reactions: '互动', + reactionsCount: '{count} 次互动', + reactionCountLabel: '{reaction}:{count}', + reactionLike: '喜欢', + reactionHelpful: '有帮助', + reactionFun: '有趣', + reactionThanks: '感谢', + chooseReaction: '选择互动', + reactionMenu: '互动菜单', + removeReaction: '取消互动', + reactionFailed: '互动失败', + commentPlaceholder: '写下评论……', + commentReplyPlaceholder: '写下回复……', + postComment: '发表评论', + postingComment: '评论中', + reply: '回复', + postReply: '发布回复', + postingReply: '回复中', + cancelReply: '取消回复', + noComments: '暂无评论', + deleteComment: '删除评论', + deleteCommentConfirm: '确认删除这条评论?', + commentDeleted: '评论已删除', + commentRequired: '请输入评论内容。', + commentFailed: '评论失败', + replyFailed: '回复失败', + deleteCommentFailed: '删除评论失败', + publish: '发布', + publishing: '发布中', + update: '更新', + updating: '更新中', + cancelEdit: '取消编辑', + empty: '暂无动态', + emptyHint: '已验证成员可以发布第一条 Life 动态。', + loading: '正在加载 Life 动态', + retryFeed: '重试加载', + loginPrompt: '使用已验证邮箱登录后即可发布。', + verifyPrompt: '完成邮箱验证后即可发布。', + editPost: '编辑动态', + deletePost: '删除动态', + saveEdit: '保存编辑', + postFailed: '发布失败', + saveFailed: '保存失败', + deleteFailed: '删除失败', + bodyRequired: '请输入动态内容。', + byUnknown: '社区成员', + edited: '已编辑', + deleteConfirm: '确认删除这条动态?', + charactersLeft: '还可以输入 {count} 个字符' + }, + admin: { + title: '管理', + subtitle: '维护系统配置,查看并删除 Wiki 数据记录。', + modules: '管理模块', + loading: '正在加载管理列表', + config: '系统配置', + configType: '系统配置类型', + checklist: 'CheckList', + pokemonList: 'Pokemon 列表', + itemList: '物品列表', + recipeList: '材料单列表', + habitatList: '栖息地列表', + languages: '语言', + newConfig: '新增{name}', + editConfig: '编辑{name}', + hasItemDrop: '有掉落物', + dragSort: '拖曳排序:{name}', + dragSortTitle: '拖曳排序', + languageCode: 'Code', + languageName: '语言名称', + enabled: '启用', + defaultLanguage: '默认语言', + sortOrder: '排序', + newLanguage: '新增语言', + editLanguage: '编辑语言', + wordings: '系统文案', + wordingLocale: '语言', + wordingModule: '模块', + wordingSurface: '端', + wordingMissingOnly: '只看缺失', + wordingKey: 'Key', + wordingValue: '文案', + defaultValue: '默认文案', + placeholders: '占位符', + missingTranslation: '缺少翻译', + allModules: '全部模块', + allSurfaces: '全部端', + surfaceFrontend: '前端', + surfaceBackend: '后端', + surfaceEmail: '邮件', + editWording: '编辑文案' + } + }, + config: { + pokemonTypes: 'Pokemon 属性', + skills: '特长', + environments: '喜欢的环境', + favoriteThings: '喜欢的东西 / 标签', + itemCategories: '物品分类', + itemUsages: '物品用途', + acquisitionMethods: '入手方式', + maps: '地图', + lifeTags: 'Life 标签' + }, + appearance: { + time: '时段', + weather: '天气', + rarity: '稀有度', + map: '地图', + maps: '出现地图', + morning: '早晨', + noon: '中午', + evening: '傍晚', + night: '晚上', + sunny: '晴天', + cloudy: '阴天', + rainy: '雨天', + stars: '{count} 星' + }, + history: { + title: '贡献记录', + createdBy: '由谁创建', + lastEdited: '最后编辑', + editHistory: '编辑历史', + before: '修改前', + after: '修改后', + author: '作者', + time: '时间', + action: '动作', + create: '创建', + update: '编辑', + delete: '删除', + empty: '暂无编辑历史' + }, + discussion: { + title: '讨论', + count: '{count} 条评论', + comment: '评论', + commentPlaceholder: '写下评论……', + replyPlaceholder: '写下回复……', + postComment: '发表评论', + postingComment: '评论中', + reply: '回复', + postReply: '发布回复', + postingReply: '回复中', + cancelReply: '取消回复', + deleteComment: '删除评论', + deleteConfirm: '确认删除这条评论?', + deletedComment: '评论已删除', + commentRequired: '请输入评论内容。', + commentFailed: '评论失败', + replyFailed: '回复失败', + deleteFailed: '删除失败', + loading: '正在加载讨论', + empty: '暂无讨论', + emptyHint: '现在发起新的讨论。', + loginPrompt: '使用已验证邮箱登录后即可评论。', + verifyPrompt: '完成邮箱验证后即可评论。', + byUnknown: '社区成员', + charactersLeft: '还可以输入 {count} 个字符' + }, + server: { + errors: { + foreignKey: '引用的数据不存在,或当前记录正在被使用', + duplicate: '同名或相同 ID 的记录已存在', + invalidField: '字段值不合法', + serverError: '服务器错误', + loginRequired: '请先登录', + verifyEmailFirst: '请先完成邮箱验证', + notFound: '未找到记录' + }, + auth: { + emailRequired: '请输入邮箱', + invalidEmail: '邮箱格式不正确', + displayNameRequired: '请输入显示名', + displayNameLength: '显示名长度需为 1 到 40 个字符', + passwordLength: '密码至少需要 8 个字符', + invalidToken: '验证链接无效或已过期', + emailAlreadyRegistered: '该邮箱已注册', + checkVerificationEmail: '请查收验证邮件', + emailVerified: '邮箱已验证', + invalidCredentials: '邮箱或密码不正确', + verifyEmailFirst: '请先完成邮箱验证' + }, + validation: { + nameRequired: '请输入名称', + recordMissing: '记录不存在', + languageCodeInvalid: '语言 Code 不合法', + languageNameRequired: '请输入语言名称', + defaultLanguageMustBeEnglish: '默认语言必须是 English', + defaultLanguageMustBeEnabled: '默认语言必须启用', + languageNotFound: '语言不存在', + defaultLanguageRequired: '必须保留一个默认语言', + defaultLanguageCannotBeDeleted: '默认语言不能删除', + selectLanguage: '请选择语言', + languageDoesNotExist: '语言不存在', + pokemonIdentifierRequired: '请输入 Pokemon 标识', + pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用', + pokemonDataNotFound: '未找到 Pokemon 数据', + taskRequired: '请输入任务', + selectTask: '请选择任务', + taskDoesNotExist: '任务不存在', + postRequired: '请输入动态内容', + postTooLong: '动态内容过长', + commentRequired: '请输入评论内容', + commentTooLong: '评论内容过长', + reactionInvalid: '互动类型不合法', + cursorInvalid: '分页位置不合法', + tagInvalid: '标签不合法', + entityTypeInvalid: '实体类型不合法', + recordInvalid: '记录不合法', + commentInvalid: '评论不合法', + selectRecord: '请选择记录', + typeMin: '请至少选择 1 个属性', + typeMax: '最多选择 2 个属性', + skillMax: '最多选择 2 个特长', + favoriteMax: '最多选择 6 个喜欢的东西', + dropItemSelectedSkill: '掉落物必须关联到已选择的特长', + pokemonIdRequired: '请输入 Pokemon ID', + pokemonNameRequired: '请输入 Pokemon 名称', + heightNonNegative: '身高必须是不小于 0 的数字', + weightNonNegative: '体重必须是不小于 0 的数字', + environmentRequired: '请选择喜欢的环境', + skillNoDrop: '这个特长不能设置掉落物', + habitatNameRequired: '请输入栖息地名称', + usageRequired: '请选择用途', + itemNameRequired: '请输入物品名称', + categoryRequired: '请选择分类', + recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单', + itemRequired: '请选择物品', + recipeFreeItem: '这个物品已标记为无材料单', + statNonNegative: '六维必须是不小于 0 的整数', + pokemonDataFileEmpty: 'Pokemon 数据文件为空', + pokemonDataFileUnavailable: 'Pokemon 数据文件不可用' + }, + wordings: { + keyNotFound: '系统文案 Key 不存在', + localeRequired: '请选择语言', + valueRequired: '请输入文案', + placeholderMismatch: '占位符必须与默认文案一致' + } + }, + email: { + auth: { + verificationSubject: '验证你的 Pokopia Wiki 邮箱', + verificationHtml: '

请点击下面的链接完成邮箱验证:

验证邮箱

链接将在 {hours} 小时后失效。

', + verificationText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。' + } + } + } +} as const; + +export type SystemWordingSurface = 'frontend' | 'backend' | 'email'; + +export type SystemWordingCatalogEntry = { + key: string; + module: string; + surface: SystemWordingSurface; + description: string; + placeholders: string[]; + values: Record; +}; + +const placeholderPattern = /\{([A-Za-z0-9_]+)\}/g; + +function isMessageTree(value: SystemWordingLeaf | SystemWordingTree): value is SystemWordingTree { + return typeof value === 'object' && value !== null; +} + +function collectPlaceholders(value: string): string[] { + return [...new Set([...value.matchAll(placeholderPattern)].map((match) => match[1]))].sort(); +} + +function mergePlaceholders(values: Record): string[] { + return [...new Set(Object.values(values).flatMap(collectPlaceholders))].sort(); +} + +function moduleForKey(key: string): string { + const parts = key.split('.'); + if ((parts[0] === 'pages' || parts[0] === 'server' || parts[0] === 'email') && parts[1]) { + return `${parts[0]}.${parts[1]}`; + } + + return parts[0] ?? 'system'; +} + +function surfaceForKey(key: string): SystemWordingSurface { + if (key.startsWith('email.')) return 'email'; + if (key.startsWith('server.')) return 'backend'; + return 'frontend'; +} + +function flattenMessages(tree: SystemWordingTree, prefix = ''): Record { + const entries: Record = {}; + + for (const [key, value] of Object.entries(tree)) { + const nextKey = prefix ? `${prefix}.${key}` : key; + if (isMessageTree(value)) { + Object.assign(entries, flattenMessages(value, nextKey)); + } else { + entries[nextKey] = value; + } + } + + return entries; +} + +export function flattenSystemWordingMessages(messages: SystemWordingMessages = systemWordingMessages): Record> { + return Object.fromEntries(Object.entries(messages).map(([locale, tree]) => [locale, flattenMessages(tree)])); +} + +export function systemWordingCatalogEntries(messages: SystemWordingMessages = systemWordingMessages): SystemWordingCatalogEntry[] { + const flattened = flattenSystemWordingMessages(messages); + const keys = Object.keys(flattened[defaultLocale] ?? {}).sort(); + + return keys.map((key) => { + const values = Object.fromEntries( + Object.entries(flattened) + .map(([locale, localeMessages]) => [locale, localeMessages[key]]) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].trim() !== '') + ); + + return { + key, + module: moduleForKey(key), + surface: surfaceForKey(key), + description: '', + placeholders: mergePlaceholders(values), + values + }; + }); +} + +export function systemWordingFallback(key: string, locale = defaultLocale): string | undefined { + const flattened = flattenSystemWordingMessages(); + return flattened[locale]?.[key] ?? flattened[defaultLocale]?.[key]; +}