feat(i18n): implement dynamic system wording management
Add database schema and API endpoints for system wording keys and values Replace hardcoded translations in frontend and backend with dynamic messages Add System Wordings management interface to Admin view
This commit is contained in:
@@ -3,4 +3,3 @@
|
||||
**/dist
|
||||
**/*.log
|
||||
**/.env
|
||||
frontend
|
||||
|
||||
23
DESIGN.md
23
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、物品、材料单、栖息地的列表排序。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
WORKDIR /app/backend
|
||||
COPY backend/package.json ./
|
||||
RUN corepack enable && pnpm install
|
||||
COPY backend/. .
|
||||
COPY data ./data
|
||||
COPY data /app/data
|
||||
COPY package.json /app/package.json
|
||||
COPY system-wordings.ts /app/system-wordings.ts
|
||||
EXPOSE 3001
|
||||
CMD ["pnpm", "run", "start"]
|
||||
|
||||
@@ -85,6 +85,39 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
CHECK (length(display_name) BETWEEN 1 AND 40)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_wording_keys (
|
||||
key text PRIMARY KEY,
|
||||
module text NOT NULL,
|
||||
surface text NOT NULL CHECK (surface IN ('frontend', 'backend', 'email')),
|
||||
description text NOT NULL DEFAULT '',
|
||||
placeholders jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(placeholders) = 'array'),
|
||||
enabled boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (key ~ '^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$'),
|
||||
CHECK (length(module) BETWEEN 1 AND 80)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS system_wording_keys_module_idx
|
||||
ON system_wording_keys(module, key);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS system_wording_keys_surface_idx
|
||||
ON system_wording_keys(surface, key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_wording_values (
|
||||
key text NOT NULL REFERENCES system_wording_keys(key) ON DELETE CASCADE,
|
||||
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
|
||||
value text NOT NULL CHECK (length(value) > 0),
|
||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (key, locale)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS system_wording_values_locale_idx
|
||||
ON system_wording_values(locale, key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
|
||||
@@ -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, string | number> = {}): string {
|
||||
const messages: Record<string, Record<AuthMessageKey, string>> = {
|
||||
en: {
|
||||
emailRequired: 'Email is required',
|
||||
invalidEmail: 'Email format is invalid',
|
||||
displayNameRequired: 'Display name is required',
|
||||
displayNameLength: 'Display name must be 1 to 40 characters',
|
||||
passwordLength: 'Password must be at least 8 characters',
|
||||
invalidToken: 'The verification link is invalid or expired',
|
||||
emailAlreadyRegistered: 'This email is already registered',
|
||||
checkVerificationEmail: 'Please check your verification email',
|
||||
emailVerified: 'Email verified',
|
||||
invalidCredentials: 'Email or password is incorrect',
|
||||
verifyEmailFirst: 'Please complete email verification first',
|
||||
emailSubject: 'Verify your Pokopia Wiki email',
|
||||
emailHtml:
|
||||
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
|
||||
emailText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.'
|
||||
},
|
||||
'zh-CN': {
|
||||
emailRequired: '请输入邮箱',
|
||||
invalidEmail: '邮箱格式不正确',
|
||||
displayNameRequired: '请输入显示名',
|
||||
displayNameLength: '显示名长度需为 1 到 40 个字符',
|
||||
passwordLength: '密码至少需要 8 个字符',
|
||||
invalidToken: '验证链接无效或已过期',
|
||||
emailAlreadyRegistered: '该邮箱已注册',
|
||||
checkVerificationEmail: '请查收验证邮件',
|
||||
emailVerified: '邮箱已验证',
|
||||
invalidCredentials: '邮箱或密码不正确',
|
||||
verifyEmailFirst: '请先完成邮箱验证',
|
||||
emailSubject: '验证你的 Pokopia Wiki 邮箱',
|
||||
emailHtml: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
|
||||
emailText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。'
|
||||
}
|
||||
function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): Promise<string> {
|
||||
const messageKeys: Record<AuthMessageKey, string> = {
|
||||
emailRequired: 'server.auth.emailRequired',
|
||||
invalidEmail: 'server.auth.invalidEmail',
|
||||
displayNameRequired: 'server.auth.displayNameRequired',
|
||||
displayNameLength: 'server.auth.displayNameLength',
|
||||
passwordLength: 'server.auth.passwordLength',
|
||||
invalidToken: 'server.auth.invalidToken',
|
||||
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
|
||||
checkVerificationEmail: 'server.auth.checkVerificationEmail',
|
||||
emailVerified: 'server.auth.emailVerified',
|
||||
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<string> {
|
||||
if (typeof value !== 'string') {
|
||||
throw statusError(authMessage(locale, 'emailRequired'), 400);
|
||||
throw statusError(await authMessage(locale, 'emailRequired'), 400);
|
||||
}
|
||||
|
||||
const email = value.trim().toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
throw statusError(authMessage(locale, 'invalidEmail'), 400);
|
||||
throw statusError(await authMessage(locale, 'invalidEmail'), 400);
|
||||
}
|
||||
|
||||
return email;
|
||||
}
|
||||
|
||||
function cleanDisplayName(value: unknown, locale: string): string {
|
||||
async function cleanDisplayName(value: unknown, locale: string): Promise<string> {
|
||||
if (typeof value !== 'string') {
|
||||
throw statusError(authMessage(locale, 'displayNameRequired'), 400);
|
||||
throw statusError(await authMessage(locale, 'displayNameRequired'), 400);
|
||||
}
|
||||
|
||||
const displayName = value.trim();
|
||||
if (displayName.length < 1 || displayName.length > 40) {
|
||||
throw statusError(authMessage(locale, 'displayNameLength'), 400);
|
||||
throw statusError(await authMessage(locale, 'displayNameLength'), 400);
|
||||
}
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
function cleanPassword(value: unknown, locale: string): string {
|
||||
async function cleanPassword(value: unknown, locale: string): Promise<string> {
|
||||
if (typeof value !== 'string' || value.length < 8) {
|
||||
throw statusError(authMessage(locale, 'passwordLength'), 400);
|
||||
throw statusError(await authMessage(locale, 'passwordLength'), 400);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function cleanToken(value: unknown, locale: string): string {
|
||||
async function cleanToken(value: unknown, locale: string): Promise<string> {
|
||||
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<void> {
|
||||
const { apiKey, from } = getEmailConfig();
|
||||
const verificationUrl = buildVerificationUrl(token);
|
||||
const subject = await authMessage(locale, 'emailSubject');
|
||||
const html = await authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours });
|
||||
const text = await authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours });
|
||||
const response = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -228,9 +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<string, unknown>, locale = defaultLocale) {
|
||||
const email = cleanEmail(payload.email, locale);
|
||||
const displayName = cleanDisplayName(payload.displayName, locale);
|
||||
const password = cleanPassword(payload.password, locale);
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
const displayName = await cleanDisplayName(payload.displayName, locale);
|
||||
const password = await cleanPassword(payload.password, locale);
|
||||
const passwordHash = await hashPassword(password);
|
||||
const verificationToken = createPlainToken();
|
||||
const verificationTokenHash = hashToken(verificationToken);
|
||||
@@ -256,7 +237,7 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
||||
);
|
||||
|
||||
if (existingUser?.email_verified_at) {
|
||||
throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409);
|
||||
throw statusError(await authMessage(locale, 'emailAlreadyRegistered'), 409);
|
||||
}
|
||||
|
||||
const user = existingUser
|
||||
@@ -295,11 +276,11 @@ export async function registerUser(payload: Record<string, unknown>, locale = de
|
||||
});
|
||||
|
||||
await sendVerificationEmail(email, verificationToken, locale);
|
||||
return { message: authMessage(locale, 'checkVerificationEmail') };
|
||||
return { message: await authMessage(locale, 'checkVerificationEmail') };
|
||||
}
|
||||
|
||||
export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
const token = cleanToken(payload.token, locale);
|
||||
const token = await cleanToken(payload.token, locale);
|
||||
const tokenHash = hashToken(token);
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
@@ -317,7 +298,7 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
||||
);
|
||||
|
||||
if (!tokenRow) {
|
||||
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
||||
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
||||
}
|
||||
|
||||
const user = await clientQueryOne<UserRow>(
|
||||
@@ -332,31 +313,31 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
throw statusError(authMessage(locale, 'invalidToken'), 400);
|
||||
throw statusError(await authMessage(locale, 'invalidToken'), 400);
|
||||
}
|
||||
|
||||
await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
|
||||
user.id
|
||||
]);
|
||||
|
||||
return { message: authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
|
||||
return { message: await authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||
const email = cleanEmail(payload.email, locale);
|
||||
const password = cleanPassword(payload.password, locale);
|
||||
const email = await cleanEmail(payload.email, locale);
|
||||
const password = await cleanPassword(payload.password, locale);
|
||||
const user = await queryOne<LoginUserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
||||
[email]
|
||||
);
|
||||
|
||||
if (!user || !(await verifyPassword(password, user.password_hash))) {
|
||||
throw statusError(authMessage(locale, 'invalidCredentials'), 401);
|
||||
throw statusError(await authMessage(locale, 'invalidCredentials'), 401);
|
||||
}
|
||||
|
||||
if (!user.email_verified_at) {
|
||||
throw statusError(authMessage(locale, 'verifyEmailFirst'), 403);
|
||||
throw statusError(await authMessage(locale, 'verifyEmailFirst'), 403);
|
||||
}
|
||||
|
||||
const sessionToken = createPlainToken();
|
||||
|
||||
@@ -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<string> {
|
||||
return systemMessage(locale, `server.errors.${key}`);
|
||||
}
|
||||
};
|
||||
|
||||
return messages[locale as keyof typeof messages]?.[key] ?? messages.en[key];
|
||||
async function notFound(reply: FastifyReply, request: FastifyRequest) {
|
||||
return reply.code(404).send({ message: await serverMessage(requestLocale(request), 'notFound') });
|
||||
}
|
||||
|
||||
async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
|
||||
@@ -140,12 +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<string, unknown>, user.id);
|
||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request, reply) => {
|
||||
@@ -233,7 +231,7 @@ app.post('/api/life-posts/:postId/comments/:commentId/replies', async (request,
|
||||
request.body as Record<string, unknown>,
|
||||
user.id
|
||||
);
|
||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.put('/api/life-posts/:id', async (request, reply) => {
|
||||
@@ -243,7 +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<string, unknown>, user.id, requestLocale(request));
|
||||
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||
return post ? post : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.put('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||
@@ -253,7 +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<string, unknown>, user.id, requestLocale(request));
|
||||
return post ? post : reply.code(404).send({ message: 'Not found' });
|
||||
return post ? post : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
|
||||
@@ -263,7 +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<string, unknown>,
|
||||
user.id
|
||||
);
|
||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', async (request, reply) => {
|
||||
@@ -326,7 +324,7 @@ app.post('/api/discussions/:entityType/:entityId/comments/:commentId/replies', a
|
||||
request.body as Record<string, unknown>,
|
||||
user.id
|
||||
);
|
||||
return comment ? reply.code(201).send(comment) : reply.code(404).send({ message: 'Not found' });
|
||||
return comment ? reply.code(201).send(comment) : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.delete('/api/discussions/comments/:id', async (request, reply) => {
|
||||
@@ -337,7 +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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/admin/system-wordings/:key', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
const { key } = request.params as { key: string };
|
||||
return updateSystemWordingValue(key, request.body as Record<string, unknown>, user.id);
|
||||
});
|
||||
|
||||
app.get('/api/admin/config/:type', async (request, reply) => {
|
||||
@@ -638,7 +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<string, unknown>, 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<string, unknown>, user.id, requestLocale(request));
|
||||
return config ? config : reply.code(404).send({ message: 'Not found' });
|
||||
return config ? config : notFound(reply, request);
|
||||
});
|
||||
|
||||
app.delete('/api/admin/config/:type/:id', async (request, reply) => {
|
||||
@@ -689,16 +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);
|
||||
|
||||
348
backend/src/systemWordingQueries.ts
Normal file
348
backend/src/systemWordingQueries.ts
Normal file
@@ -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<SystemWordingSurface>(['frontend', 'backend', 'email']);
|
||||
|
||||
const legacyMessageKeys = new Map<string, string>([
|
||||
['Record does not exist', 'server.validation.recordMissing'],
|
||||
['Language code is invalid', 'server.validation.languageCodeInvalid'],
|
||||
['Language name is required', 'server.validation.languageNameRequired'],
|
||||
['Default language must be English', 'server.validation.defaultLanguageMustBeEnglish'],
|
||||
['Default language must be enabled', 'server.validation.defaultLanguageMustBeEnabled'],
|
||||
['Language not found', 'server.validation.languageNotFound'],
|
||||
['A default language is required', 'server.validation.defaultLanguageRequired'],
|
||||
['Default language cannot be deleted', 'server.validation.defaultLanguageCannotBeDeleted'],
|
||||
['Please select a language', 'server.validation.selectLanguage'],
|
||||
['Language does not exist', 'server.validation.languageDoesNotExist'],
|
||||
['Pokemon identifier is required', 'server.validation.pokemonIdentifierRequired'],
|
||||
['Pokemon type data is unavailable', 'server.validation.pokemonTypeDataUnavailable'],
|
||||
['Pokemon data was not found', 'server.validation.pokemonDataNotFound'],
|
||||
['Please enter a task', 'server.validation.taskRequired'],
|
||||
['Please select a task', 'server.validation.selectTask'],
|
||||
['Task does not exist', 'server.validation.taskDoesNotExist'],
|
||||
['Please enter a post', 'server.validation.postRequired'],
|
||||
['Post is too long', 'server.validation.postTooLong'],
|
||||
['Please enter a comment', 'server.validation.commentRequired'],
|
||||
['Comment is too long', 'server.validation.commentTooLong'],
|
||||
['Reaction is invalid', 'server.validation.reactionInvalid'],
|
||||
['Cursor is invalid', 'server.validation.cursorInvalid'],
|
||||
['Tag is invalid', 'server.validation.tagInvalid'],
|
||||
['Entity type is invalid', 'server.validation.entityTypeInvalid'],
|
||||
['Record is invalid', 'server.validation.recordInvalid'],
|
||||
['Comment is invalid', 'server.validation.commentInvalid'],
|
||||
['Please select a record', 'server.validation.selectRecord'],
|
||||
['Choose at least 1 type', 'server.validation.typeMin'],
|
||||
['Choose at most 2 types', 'server.validation.typeMax'],
|
||||
['Choose at most 2 specialities', 'server.validation.skillMax'],
|
||||
['Choose at most 6 favourites', 'server.validation.favoriteMax'],
|
||||
['Drop items must be linked to selected specialities', 'server.validation.dropItemSelectedSkill'],
|
||||
['Pokemon ID is required', 'server.validation.pokemonIdRequired'],
|
||||
['Pokemon name is required', 'server.validation.pokemonNameRequired'],
|
||||
['Height must be a non-negative number', 'server.validation.heightNonNegative'],
|
||||
['Weight must be a non-negative number', 'server.validation.weightNonNegative'],
|
||||
['Ideal Habitat is required', 'server.validation.environmentRequired'],
|
||||
['This speciality cannot have a drop item', 'server.validation.skillNoDrop'],
|
||||
['Habitat name is required', 'server.validation.habitatNameRequired'],
|
||||
['Usage is required', 'server.validation.usageRequired'],
|
||||
['Item name is required', 'server.validation.itemNameRequired'],
|
||||
['Category is required', 'server.validation.categoryRequired'],
|
||||
['An item with a recipe cannot be marked as recipe-free', 'server.validation.recipeFreeWithRecipe'],
|
||||
['Item is required', 'server.validation.itemRequired'],
|
||||
['This item is marked as recipe-free', 'server.validation.recipeFreeItem'],
|
||||
['Name is required', 'server.validation.nameRequired']
|
||||
]);
|
||||
|
||||
function validationError(message: string): ValidationError {
|
||||
const error = new Error(message) as ValidationError;
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
function cleanLocale(value: unknown): string {
|
||||
const locale = typeof value === 'string' ? value.trim() : '';
|
||||
return localePattern.test(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
function requireLocale(value: unknown): string {
|
||||
const locale = typeof value === 'string' ? value.trim() : '';
|
||||
if (!localePattern.test(locale)) {
|
||||
throw validationError('server.wordings.localeRequired');
|
||||
}
|
||||
return locale;
|
||||
}
|
||||
|
||||
function requireWordingKey(value: unknown): string {
|
||||
const key = typeof value === 'string' ? value.trim() : '';
|
||||
if (!wordingKeyPattern.test(key)) {
|
||||
throw validationError('server.wordings.keyNotFound');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function cleanSurface(value: unknown): SystemWordingSurface | '' {
|
||||
const surface = typeof value === 'string' ? value.trim() : '';
|
||||
return surfaces.has(surface as SystemWordingSurface) ? (surface as SystemWordingSurface) : '';
|
||||
}
|
||||
|
||||
function collectPlaceholders(value: string): string[] {
|
||||
return [...new Set([...value.matchAll(placeholderPattern)].map((match) => match[1]))].sort();
|
||||
}
|
||||
|
||||
function placeholdersMatch(first: string[], second: string[]): boolean {
|
||||
return first.length === second.length && first.every((placeholder, index) => placeholder === second[index]);
|
||||
}
|
||||
|
||||
function interpolate(message: string, params: Record<string, string | number>): string {
|
||||
return Object.entries(params).reduce(
|
||||
(nextMessage, [key, value]) => nextMessage.replaceAll(`{${key}}`, String(value)),
|
||||
message
|
||||
);
|
||||
}
|
||||
|
||||
function setNestedMessage(target: SystemWordingTree, key: string, value: string): void {
|
||||
const parts = key.split('.');
|
||||
let node = target;
|
||||
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const current = node[part];
|
||||
if (typeof current !== 'object' || current === null) {
|
||||
node[part] = {};
|
||||
}
|
||||
node = node[part] as SystemWordingTree;
|
||||
}
|
||||
|
||||
node[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function nestedMessages(rows: Array<{ key: string; value: string }>): SystemWordingTree {
|
||||
const messages: SystemWordingTree = {};
|
||||
for (const row of rows) {
|
||||
setNestedMessage(messages, row.key, row.value);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
|
||||
function normalizePlaceholders(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((item) => String(item)).sort() : [];
|
||||
}
|
||||
|
||||
function legacyMessageKey(message: string): string | null {
|
||||
if (message.startsWith('server.') || message.startsWith('email.')) {
|
||||
return message;
|
||||
}
|
||||
if (message.endsWith(' must be a non-negative integer')) {
|
||||
return 'server.validation.statNonNegative';
|
||||
}
|
||||
if (message.endsWith(' is empty')) {
|
||||
return 'server.validation.pokemonDataFileEmpty';
|
||||
}
|
||||
if (message.startsWith('Pokemon data file ') && message.endsWith(' is unavailable')) {
|
||||
return 'server.validation.pokemonDataFileUnavailable';
|
||||
}
|
||||
return legacyMessageKeys.get(message) ?? null;
|
||||
}
|
||||
|
||||
export async function syncSystemWordingCatalog(): Promise<void> {
|
||||
const entries = systemWordingCatalogEntries();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
for (const entry of entries) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO system_wording_keys (key, module, surface, description, placeholders)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET module = EXCLUDED.module,
|
||||
surface = EXCLUDED.surface,
|
||||
description = EXCLUDED.description,
|
||||
placeholders = EXCLUDED.placeholders,
|
||||
updated_at = now()
|
||||
`,
|
||||
[entry.key, entry.module, entry.surface, entry.description, JSON.stringify(entry.placeholders)]
|
||||
);
|
||||
|
||||
for (const [locale, value] of Object.entries(entry.values)) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO system_wording_values (key, locale, value)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key, locale) DO NOTHING
|
||||
`,
|
||||
[entry.key, locale, value]
|
||||
);
|
||||
}
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function systemMessage(
|
||||
locale: string,
|
||||
key: string,
|
||||
params: Record<string, string | number> = {}
|
||||
): Promise<string> {
|
||||
const requestedLocale = cleanLocale(locale);
|
||||
|
||||
try {
|
||||
const result = await pool.query<{ value: string }>(
|
||||
`
|
||||
SELECT COALESCE(requested.value, fallback.value) AS value
|
||||
FROM system_wording_keys k
|
||||
LEFT JOIN system_wording_values requested
|
||||
ON requested.key = k.key
|
||||
AND requested.locale = $2
|
||||
LEFT JOIN system_wording_values fallback
|
||||
ON fallback.key = k.key
|
||||
AND fallback.locale = $3
|
||||
WHERE k.key = $1
|
||||
`,
|
||||
[key, requestedLocale, defaultLocale]
|
||||
);
|
||||
const message = result.rows[0]?.value ?? systemWordingFallback(key, requestedLocale) ?? key;
|
||||
return interpolate(message, params);
|
||||
} catch {
|
||||
return interpolate(systemWordingFallback(key, requestedLocale) ?? key, params);
|
||||
}
|
||||
}
|
||||
|
||||
export async function localizedStatusMessage(locale: string, message: string): Promise<string> {
|
||||
const key = legacyMessageKey(message);
|
||||
return key ? systemMessage(locale, key) : message;
|
||||
}
|
||||
|
||||
export async function getSystemWordings(locale: string) {
|
||||
const requestedLocale = cleanLocale(locale);
|
||||
const rows = await query<{ key: string; value: string; missing: boolean }>(
|
||||
`
|
||||
SELECT
|
||||
k.key,
|
||||
COALESCE(requested.value, fallback.value, '') AS value,
|
||||
($1 <> $2 AND requested.value IS NULL) AS missing
|
||||
FROM system_wording_keys k
|
||||
LEFT JOIN system_wording_values requested
|
||||
ON requested.key = k.key
|
||||
AND requested.locale = $1
|
||||
LEFT JOIN system_wording_values fallback
|
||||
ON fallback.key = k.key
|
||||
AND fallback.locale = $2
|
||||
WHERE k.enabled = true
|
||||
ORDER BY k.key
|
||||
`,
|
||||
[requestedLocale, defaultLocale]
|
||||
);
|
||||
|
||||
return {
|
||||
locale: requestedLocale,
|
||||
fallbackLocale: defaultLocale,
|
||||
messages: nestedMessages(rows),
|
||||
missingKeys: rows.filter((row) => row.missing).map((row) => row.key)
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSystemWordingRows(filters: Record<string, unknown>) {
|
||||
const locale = cleanLocale(filters.locale);
|
||||
const module = typeof filters.module === 'string' ? filters.module.trim() : '';
|
||||
const surface = cleanSurface(filters.surface);
|
||||
const missingOnly = filters.missing === 'true' || filters.missing === true;
|
||||
|
||||
return query<SystemWordingValueRow>(
|
||||
`
|
||||
SELECT
|
||||
k.key,
|
||||
k.module,
|
||||
k.surface,
|
||||
k.description,
|
||||
k.placeholders,
|
||||
COALESCE(requested.value, '') AS value,
|
||||
COALESCE(fallback.value, '') AS "defaultValue",
|
||||
($1 <> $2 AND requested.value IS NULL) AS missing,
|
||||
requested.updated_at AS "updatedAt",
|
||||
CASE
|
||||
WHEN updated_user.id IS NULL THEN NULL
|
||||
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
|
||||
END AS "updatedBy"
|
||||
FROM system_wording_keys k
|
||||
LEFT JOIN system_wording_values requested
|
||||
ON requested.key = k.key
|
||||
AND requested.locale = $1
|
||||
LEFT JOIN system_wording_values fallback
|
||||
ON fallback.key = k.key
|
||||
AND fallback.locale = $2
|
||||
LEFT JOIN users updated_user ON updated_user.id = requested.updated_by_user_id
|
||||
WHERE k.enabled = true
|
||||
AND ($3 = '' OR k.module = $3)
|
||||
AND ($4 = '' OR k.surface = $4)
|
||||
AND ($5 = false OR ($1 <> $2 AND requested.value IS NULL))
|
||||
ORDER BY k.module, k.key
|
||||
`,
|
||||
[locale, defaultLocale, module, surface, missingOnly]
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateSystemWordingValue(keyValue: string, payload: Record<string, unknown>, userId: number) {
|
||||
const key = requireWordingKey(keyValue);
|
||||
const locale = requireLocale(payload.locale);
|
||||
const value = typeof payload.value === 'string' ? payload.value.trim() : '';
|
||||
|
||||
const keyRow = await pool.query<{ placeholders: unknown }>('SELECT placeholders FROM system_wording_keys WHERE key = $1', [key]);
|
||||
const placeholders = normalizePlaceholders(keyRow.rows[0]?.placeholders);
|
||||
if (keyRow.rowCount === 0) {
|
||||
throw validationError('server.wordings.keyNotFound');
|
||||
}
|
||||
|
||||
if (locale === defaultLocale && value === '') {
|
||||
throw validationError('server.wordings.valueRequired');
|
||||
}
|
||||
|
||||
if (value !== '' && !placeholdersMatch(placeholders, collectPlaceholders(value))) {
|
||||
throw validationError('server.wordings.placeholderMismatch');
|
||||
}
|
||||
|
||||
const result = await pool.query<{ code: string }>('SELECT code FROM languages WHERE code = $1', [locale]);
|
||||
if (result.rowCount === 0) {
|
||||
throw validationError('server.wordings.localeRequired');
|
||||
}
|
||||
|
||||
if (value === '') {
|
||||
await pool.query('DELETE FROM system_wording_values WHERE key = $1 AND locale = $2', [key, locale]);
|
||||
} else {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO system_wording_values (key, locale, value, created_by_user_id, updated_by_user_id)
|
||||
VALUES ($1, $2, $3, $4, $4)
|
||||
ON CONFLICT (key, locale) DO UPDATE
|
||||
SET value = EXCLUDED.value,
|
||||
updated_by_user_id = EXCLUDED.updated_by_user_id,
|
||||
updated_at = now()
|
||||
`,
|
||||
[key, locale, value, userId]
|
||||
);
|
||||
}
|
||||
|
||||
return listSystemWordingRows({ locale });
|
||||
}
|
||||
@@ -10,5 +10,5 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts", "../system-wordings.ts"]
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ services:
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
context: .
|
||||
dockerfile: frontend/Dockerfile
|
||||
environment:
|
||||
VITE_API_BASE_URL: http://localhost:3001
|
||||
ports:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json ./
|
||||
RUN corepack enable && pnpm install
|
||||
COPY . .
|
||||
COPY frontend/. .
|
||||
COPY package.json /app/package.json
|
||||
COPY system-wordings.ts /app/system-wordings.ts
|
||||
EXPOSE 3000
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
iconPokemon,
|
||||
iconRecipe
|
||||
} from './icons';
|
||||
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
|
||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
|
||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
@@ -87,12 +87,15 @@ async function loadLanguages() {
|
||||
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
||||
setCurrentLocale('en');
|
||||
}
|
||||
|
||||
await loadSystemWordings(getCurrentLocale());
|
||||
} catch {
|
||||
// Keep the built-in language list when the API is not ready yet.
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocale(value: string) {
|
||||
async function updateLocale(value: string) {
|
||||
await loadSystemWordings(value);
|
||||
setCurrentLocale(value);
|
||||
}
|
||||
|
||||
|
||||
1017
frontend/src/i18n.ts
1017
frontend/src/i18n.ts
File diff suppressed because it is too large
Load Diff
@@ -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<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
|
||||
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
|
||||
deleteLanguage: (code: string) => deleteJson(`/api/admin/languages/${code}`),
|
||||
systemWordings: (params: { locale?: string; module?: string; surface?: string; missing?: string } = {}) =>
|
||||
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
|
||||
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
|
||||
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
|
||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||
verifyEmail: (token: string) =>
|
||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
iconTranslate,
|
||||
type AppIcon
|
||||
} from '../icons';
|
||||
import { defaultLocale, getCurrentLocale, setCurrentLocale } from '../i18n';
|
||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||
import {
|
||||
api,
|
||||
type AuthUser,
|
||||
@@ -37,15 +37,18 @@ import {
|
||||
type Pokemon,
|
||||
type Recipe,
|
||||
type Skill,
|
||||
type SystemWording,
|
||||
type SystemWordingSurface,
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
type AdminTab = 'config' | 'languages' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type AdminTab = 'config' | 'languages' | 'wordings' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
||||
|
||||
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
config: iconAdmin,
|
||||
languages: iconTranslate,
|
||||
wordings: iconTranslate,
|
||||
checklist: iconChecklist,
|
||||
pokemon: iconPokemon,
|
||||
items: iconItem,
|
||||
@@ -58,6 +61,7 @@ const { locale, t } = useI18n();
|
||||
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||
{ key: 'config', label: t('pages.admin.config') },
|
||||
{ key: 'languages', label: t('pages.admin.languages') },
|
||||
{ key: 'wordings', label: t('pages.admin.wordings') },
|
||||
{ key: 'checklist', label: t('pages.admin.checklist') },
|
||||
{ key: 'pokemon', label: 'Pokemon' },
|
||||
{ key: 'items', label: t('pages.items.title') },
|
||||
@@ -86,6 +90,7 @@ const pokemonRows = ref<Pokemon[]>([]);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const recipeRows = ref<Recipe[]>([]);
|
||||
const habitatRows = ref<Habitat[]>([]);
|
||||
const wordingRows = ref<SystemWording[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const busy = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
@@ -93,10 +98,16 @@ const message = ref('');
|
||||
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
|
||||
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
||||
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
||||
const editingLanguageCode = ref('');
|
||||
const configModalOpen = ref(false);
|
||||
const checklistModalOpen = ref(false);
|
||||
const languageModalOpen = ref(false);
|
||||
const wordingModalOpen = ref(false);
|
||||
const wordingLocale = ref(getCurrentLocale());
|
||||
const wordingModule = ref('');
|
||||
const wordingSurface = ref<SystemWordingSurface | ''>('');
|
||||
const wordingMissingOnly = ref(false);
|
||||
|
||||
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
|
||||
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
|
||||
@@ -140,6 +151,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(() => {
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'wordings'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.wordings') }}</h2>
|
||||
</div>
|
||||
<div class="toolbar system-wording-toolbar">
|
||||
<div class="field">
|
||||
<label for="wording-locale">{{ t('pages.admin.wordingLocale') }}</label>
|
||||
<select id="wording-locale" v-model="wordingLocale" :disabled="busy" @change="reloadWordings">
|
||||
<option v-for="language in wordingLocaleOptions" :key="language.code" :value="language.code">
|
||||
{{ language.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-module">{{ t('pages.admin.wordingModule') }}</label>
|
||||
<select id="wording-module" v-model="wordingModule" :disabled="busy">
|
||||
<option value="">{{ t('pages.admin.allModules') }}</option>
|
||||
<option v-for="module in wordingModules" :key="module" :value="module">{{ module }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-surface">{{ t('pages.admin.wordingSurface') }}</label>
|
||||
<select id="wording-surface" v-model="wordingSurface" :disabled="busy">
|
||||
<option value="">{{ t('pages.admin.allSurfaces') }}</option>
|
||||
<option value="frontend">{{ t('pages.admin.surfaceFrontend') }}</option>
|
||||
<option value="backend">{{ t('pages.admin.surfaceBackend') }}</option>
|
||||
<option value="email">{{ t('pages.admin.surfaceEmail') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="check-row system-wording-toolbar__check">
|
||||
<label>
|
||||
<input v-model="wordingMissingOnly" type="checkbox" :disabled="busy" />
|
||||
{{ t('pages.admin.wordingMissingOnly') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="filteredWordingRows.length" class="row-list system-wording-list">
|
||||
<li v-for="item in filteredWordingRows" :key="item.key">
|
||||
<span class="system-wording-row">
|
||||
<strong>{{ item.key }}</strong>
|
||||
<span class="system-wording-row__meta">
|
||||
<span class="config-flag">{{ item.module }}</span>
|
||||
<span class="config-flag">{{ t(`pages.admin.surface${item.surface.charAt(0).toUpperCase()}${item.surface.slice(1)}`) }}</span>
|
||||
<span v-if="item.missing" class="config-flag">{{ t('pages.admin.missingTranslation') }}</span>
|
||||
</span>
|
||||
<span class="system-wording-row__value">{{ item.value || item.defaultValue }}</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="editWording(item)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ReorderableList
|
||||
@@ -932,5 +1067,39 @@ onMounted(() => {
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="wordingModalOpen" :title="wordingModalTitle" :close-label="t('common.close')" size="wide" @close="closeWordingModal">
|
||||
<form id="admin-wording-form" class="modal-edit-form" @submit.prevent="saveWording">
|
||||
<div class="field">
|
||||
<label for="wording-key">{{ t('pages.admin.wordingKey') }}</label>
|
||||
<input id="wording-key" :value="wordingForm.key" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-default-value">{{ t('pages.admin.defaultValue') }}</label>
|
||||
<textarea id="wording-default-value" :value="wordingForm.defaultValue" disabled></textarea>
|
||||
</div>
|
||||
<div v-if="wordingForm.placeholders.length" class="field">
|
||||
<span class="field-label">{{ t('pages.admin.placeholders') }}</span>
|
||||
<span class="chips">
|
||||
<span v-for="placeholder in wordingForm.placeholders" :key="placeholder" class="chip">{{ placeholder }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-value">{{ t('pages.admin.wordingValue') }}</label>
|
||||
<textarea id="wording-value" v-model="wordingForm.value" :required="wordingForm.locale === defaultLocale"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-wording-form" class="link-button" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeWordingModal">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
"strict": true,
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts", "../system-wordings.ts"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "pokopia",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.2",
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel --filter @pokopia/backend --filter @pokopia/frontend dev",
|
||||
|
||||
1247
system-wordings.ts
Normal file
1247
system-wordings.ts
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user