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]; +}