feat(life): add game versions and 5-star ratings to posts

Support associating life posts with specific game versions
Allow 1-5 star ratings on posts in rateable categories
Add feed filters for game version, rateable status, and top-rated sorting
This commit is contained in:
2026-05-03 18:38:33 +08:00
parent 4ebb45aa94
commit 105274eec8
10 changed files with 856 additions and 58 deletions

View File

@@ -61,6 +61,7 @@
- 栖息地
- 每日 CheckList Task
- Life Category
- Game Version
- 支持翻译的字段:
- `name`
- `title`
@@ -384,8 +385,17 @@
- 名称
- 是否默认选中:最多一个 Life Category 可设为默认;新建 Life Post 时默认选中该分类。
- 是否可评分Rateable Life Category 下的 Life Post 可由用户进行 1-5 星评分。
- 用于 Life Post 分类展示和 Feed 筛选。
### Game Version
- 版本号 / 名称
- ChangeLog可为空用于说明该版本主要变化。
- 用于 Life Post 发布时选择关联的游戏版本。
- Life Post 可不选择游戏版本;未选择时前台不展示版本号。
- Game Version 支持管理端创建、编辑、删除和排序。
## Pokemon
Pokemon 可配置:
@@ -643,10 +653,12 @@ Life Post 可配置:
- Post 内容正文
- Category使用 Life Category 配置,必须且只能选择 1 个
- Game Version可为空使用 Game Version 配置;有值时在 Post 卡片展示版本号。
- 创建者、最后编辑者、创建时间、最后编辑时间
- 评论
- 评论回复:仅支持回复顶层评论,不做无限嵌套
- Reactions`like``helpful``fun``thanks`
- RatingsRateable Category 下的 Post 支持 1-5 星评分;每个用户每条 Post 最多一条评分,重复评分会替换原评分。
前台行为:
@@ -663,9 +675,14 @@ Life Post 可配置:
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
- 已注册并完成邮箱验证且拥有 `life.ratings.set` 权限的用户可以对 Rateable Life Post 设置或取消 1-5 星评分;非 Rateable Category 下的 Post 不显示评分控件,也不能通过 API 评分。
- Life Post 展示评分时只展示平均分、评分人数和当前用户自己的评分;不展示其他用户的评分明细。
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
- Feed 使用 Tabs 展示 Life Category 筛选;包含 All 和后台配置的 Life Category点击 Category 后按该 Category 筛选,搜索和 Category 筛选可以同时生效。
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言搜索、Category 和语言筛选可以同时生效。
- Feed 支持按 Game Version 筛选All versions 表示不过滤版本。
- Feed 支持 Rateable 筛选All 表示不过滤Rateable only 只展示可评分 Category 下的 Post。
- Feed 支持排序Latest 默认按创建时间倒序Oldest 按创建时间正序Top rated 按平均评分倒序,同分时按创建时间倒序。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发或置顶。
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
@@ -680,6 +697,8 @@ API 暴露边界:
- Life Post 作者信息只返回 `id``displayName`
- Life Post Category 只返回 `id` 和按当前语言解析后的 `name`
- Life Post Game Version 只返回 `id`、展示用 `name` 和可展示 `changeLog`;未选择版本时返回 `null`
- Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
- Life Post 可返回面向用户展示所需的审核状态、审核语言区和是否可重审不返回内部错误、AI prompt、模型响应或 retry 细节。
- Life Comment 作者信息只返回 `id``displayName`
- Life Reaction 对外只返回按类型汇总的数量和当前用户自己的 Reaction不返回其他用户的 Reaction 明细。
@@ -787,7 +806,7 @@ API 暴露边界:
- `GET /api/items/:id`
- `GET /api/recipes`
- `GET /api/recipes/:id`
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区。
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort``latest``oldest``top-rated`
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
@@ -846,6 +865,9 @@ API 暴露边界:
- Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction`
- `DELETE /api/life-posts/:id/reaction`
- Life Rating 的设置、替换和取消。
- `PUT /api/life-posts/:id/rating`
- `DELETE /api/life-posts/:id/rating`
- 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
- 语言的查看、创建、更新、删除、排序需要对应 `admin.languages.*` 权限。

View File

@@ -238,6 +238,7 @@ VALUES
('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true),
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', 'Life', true),
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true)
@@ -321,6 +322,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete',
'life.comments.delete-any',
'life.reactions.set',
'life.ratings.set',
'discussions.comments.create',
'discussions.comments.delete',
'discussions.comments.delete-any'
@@ -374,6 +376,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.create',
'life.comments.delete',
'life.reactions.set',
'life.ratings.set',
'discussions.comments.create',
'discussions.comments.delete'
])
@@ -395,6 +398,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.create',
'life.comments.delete',
'life.reactions.set',
'life.ratings.set',
'discussions.comments.create',
'discussions.comments.delete'
])
@@ -406,6 +410,13 @@ WHERE r.key = 'member'
)
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'life.ratings.set'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
WITH first_owner_user AS (
SELECT u.id
FROM users u
@@ -524,6 +535,7 @@ CREATE TABLE IF NOT EXISTS life_tags (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
is_default boolean NOT NULL DEFAULT false,
is_rateable boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 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,
@@ -531,10 +543,25 @@ CREATE TABLE IF NOT EXISTS life_tags (
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS game_versions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
change_log text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 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()
);
CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
ON game_versions(sort_order, id);
CREATE TABLE IF NOT EXISTS life_posts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000),
category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ai_moderation_content_hash text,
@@ -611,6 +638,21 @@ CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx
ON life_post_reactions(user_id, updated_at DESC, post_id DESC);
CREATE TABLE IF NOT EXISTS life_post_ratings (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
ON life_post_ratings(post_id, rating);
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
@@ -904,9 +946,27 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ON entity_discussion_comments(created_by_user_id);
ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS game_versions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
change_log text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 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()
);
CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
ON game_versions(sort_order, id);
ALTER TABLE life_posts
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ADD COLUMN IF NOT EXISTS category_id integer REFERENCES life_tags(id) ON DELETE RESTRICT,
ADD COLUMN IF NOT EXISTS game_version_id integer REFERENCES game_versions(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS ai_moderation_content_hash text,
ADD COLUMN IF NOT EXISTS ai_moderation_checked_at timestamptz,
@@ -928,6 +988,25 @@ CREATE INDEX IF NOT EXISTS life_posts_category_idx
ON life_posts(category_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS life_posts_game_version_idx
ON life_posts(game_version_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS life_post_ratings (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rating integer NOT NULL CHECK (rating BETWEEN 1 AND 5),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (post_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_post_ratings_post_idx
ON life_post_ratings(post_id, rating);
CREATE INDEX IF NOT EXISTS life_post_ratings_user_idx
ON life_post_ratings(user_id, updated_at DESC, post_id DESC);
ALTER TABLE life_post_comments
ADD COLUMN IF NOT EXISTS ai_moderation_status text NOT NULL DEFAULT 'unreviewed' CHECK (ai_moderation_status IN ('unreviewed', 'reviewing', 'approved', 'rejected', 'failed')),
ADD COLUMN IF NOT EXISTS ai_moderation_language_code text REFERENCES languages(code) ON DELETE SET NULL,

View File

@@ -38,7 +38,8 @@ type EntityType =
| 'maps'
| 'habitats'
| 'daily-checklist-items'
| 'life-tags';
| 'life-tags'
| 'game-versions';
type ConfigType =
| 'pokemon-types'
@@ -49,13 +50,16 @@ type ConfigType =
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags';
| 'life-tags'
| 'game-versions';
type ConfigDefinition = {
table: string;
entityType: EntityType;
hasItemDrop?: boolean;
hasDefault?: boolean;
hasRateable?: boolean;
hasChangeLog?: boolean;
};
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
type SortableContentDefinition = {
@@ -180,6 +184,7 @@ type DailyChecklistPayload = {
type LifePostPayload = {
body: string;
categoryId: number;
gameVersionId: number | null;
languageCode: string | null;
};
@@ -251,7 +256,10 @@ type LifePostRow = {
updatedAt: Date;
author: { id: number; displayName: string } | null;
updatedBy: { id: number; displayName: string } | null;
category: { id: number; name: string } | null;
category: { id: number; name: string; isRateable: boolean } | null;
gameVersion: { id: number; name: string; changeLog: string } | null;
ratingAverage: number | null;
ratingCount: number;
};
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
@@ -259,13 +267,17 @@ type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
commentCount: number;
reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null;
myRating: number | null;
};
type LifePostCursor = {
createdAt: string;
id: number;
ratingAverage?: number;
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
type LifePostFilters = {
authorId?: number;
};
@@ -460,7 +472,8 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true }
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }
};
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
@@ -685,9 +698,16 @@ function optionSelect(
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
}
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean }>> {
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean; isRateable: boolean }>> {
const name = localizedName('life-tags', 'lc', locale);
return query(`SELECT lc.id, ${name} AS name, lc.is_default AS "isDefault" FROM life_tags lc ORDER BY ${orderByEntity('lc')}`);
return query(
`SELECT lc.id, ${name} AS name, lc.is_default AS "isDefault", lc.is_rateable AS "isRateable" FROM life_tags lc ORDER BY ${orderByEntity('lc')}`
);
}
function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: string; changeLog: string }>> {
const name = localizedName('game-versions', 'gv', locale);
return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
}
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> {
@@ -731,6 +751,12 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
if (definition.hasDefault) {
columns.push(`c.is_default AS "isDefault"`);
}
if (definition.hasRateable) {
columns.push(`c.is_rateable AS "isRateable"`);
}
if (definition.hasChangeLog) {
columns.push(`c.change_log AS "changeLog"`);
}
return columns.join(', ');
}
@@ -748,6 +774,14 @@ function requirePositiveInteger(value: unknown, message: string): number {
return numberValue;
}
function optionalPositiveInteger(value: unknown, message: string): number | null {
if (value === undefined || value === null || value === '') {
return null;
}
return requirePositiveInteger(value, message);
}
function cleanName(value: unknown, message = 'server.validation.nameRequired'): string {
if (typeof value !== 'string' || value.trim() === '') {
throw validationError(message);
@@ -2056,7 +2090,8 @@ export async function getOptions(locale = defaultLocale) {
itemUsages,
acquisitionMethods,
maps,
lifeCategories
lifeCategories,
gameVersions
] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale),
@@ -2066,7 +2101,8 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('item_usages', 'item-usages', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale)
lifeCategoryOptions(locale),
gameVersionOptions(locale)
]);
return {
@@ -2079,7 +2115,8 @@ export async function getOptions(locale = defaultLocale) {
acquisitionMethods,
itemTags: favoriteThings,
maps,
lifeCategories
lifeCategories,
gameVersions
};
}
@@ -2221,10 +2258,12 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
throw validationError('server.validation.postTooLong');
}
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.lifeCategoryRequired');
const gameVersionId = optionalPositiveInteger(payload.gameVersionId, 'server.validation.gameVersionInvalid');
return {
body,
categoryId,
gameVersionId,
languageCode: cleanModerationLanguageCode(payload.languageCode)
};
}
@@ -2268,6 +2307,15 @@ function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
return cleanLifeReactionType(reactionType);
}
function cleanLifeRating(value: unknown): number {
const rating = Number(value);
if (!Number.isInteger(rating) || rating < 1 || rating > 5) {
throw validationError('server.validation.ratingInvalid');
}
return rating;
}
function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
const source = asString(value);
if (!source) {
@@ -2322,6 +2370,7 @@ function addModerationLanguageCondition(
function lifePostProjection(locale = defaultLocale): string {
const categoryName = localizedName('life-tags', 'lc', locale);
const gameVersionName = localizedName('game-versions', 'gv', locale);
return `
SELECT
@@ -2342,10 +2391,27 @@ function lifePostProjection(locale = defaultLocale): string {
END AS "updatedBy",
CASE
WHEN lc.id IS NULL THEN NULL
ELSE json_build_object('id', lc.id, 'name', ${categoryName})
END AS category
ELSE json_build_object('id', lc.id, 'name', ${categoryName}, 'isRateable', lc.is_rateable)
END AS category,
CASE
WHEN gv.id IS NULL THEN NULL
ELSE json_build_object('id', gv.id, 'name', ${gameVersionName}, 'changeLog', gv.change_log)
END AS "gameVersion",
CASE
WHEN rating_stats.rating_count = 0 THEN NULL
ELSE rating_stats.rating_average::double precision
END AS "ratingAverage",
rating_stats.rating_count AS "ratingCount"
FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_id
LEFT JOIN game_versions gv ON gv.id = lp.game_version_id
LEFT JOIN LATERAL (
SELECT
ROUND(AVG(lpr.rating)::numeric, 2) AS rating_average,
COUNT(*)::integer AS rating_count
FROM life_post_ratings lpr
WHERE lpr.post_id = lp.id
) rating_stats ON true
LEFT JOIN users created_user ON created_user.id = lp.created_by_user_id
LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
`;
@@ -2361,6 +2427,22 @@ function cleanLifePostLimit(value: QueryValue): number {
return Number.isInteger(limit) && limit > 0 ? Math.min(limit, maxLifePostLimit) : defaultLifePostLimit;
}
function cleanLifePostSort(value: QueryValue): LifePostSort {
const sort = asString(value);
return sort === 'oldest' || sort === 'top-rated' ? sort : 'latest';
}
function cleanRateableFilter(value: QueryValue): boolean | null {
const rateable = asString(value);
if (rateable === 'true') {
return true;
}
if (rateable === 'false') {
return false;
}
return null;
}
function cleanCommentLimit(value: QueryValue): number {
const rawLimit = asString(value);
if (rawLimit === undefined || rawLimit === '') {
@@ -2381,12 +2463,19 @@ function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
const id = Number(cursor.id);
const ratingAverage = cursor.ratingAverage === undefined ? undefined : Number(cursor.ratingAverage);
if (!createdAt || Number.isNaN(new Date(createdAt).getTime()) || !Number.isInteger(id) || id <= 0) {
if (
!createdAt ||
Number.isNaN(new Date(createdAt).getTime()) ||
!Number.isInteger(id) ||
id <= 0 ||
(ratingAverage !== undefined && (Number.isNaN(ratingAverage) || ratingAverage < 0))
) {
throw validationError('server.validation.cursorInvalid');
}
return { createdAt, id };
return { createdAt, id, ratingAverage };
} catch (error) {
if (error instanceof Error && 'statusCode' in error) {
throw error;
@@ -2396,7 +2485,10 @@ function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
}
function encodeLifePostCursor(post: LifePostRow): string {
return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
return Buffer.from(
JSON.stringify({ createdAt: post.createdAtCursor, id: post.id, ratingAverage: post.ratingAverage ?? 0 }),
'utf8'
).toString('base64url');
}
function encodeProfileCursor(cursor: LifePostCursor): string {
@@ -2443,7 +2535,8 @@ function hydrateLifePost(
commentPreviewByPost: Map<number, LifeComment[]>,
commentCountsByPost: Map<number, number>,
countsByPost: Map<number, LifeReactionCounts>,
myReactionsByPost: Map<number, LifeReactionType>
myReactionsByPost: Map<number, LifeReactionType>,
myRatingsByPost: Map<number, number>
): LifePost {
return {
id: post.id,
@@ -2455,10 +2548,14 @@ function hydrateLifePost(
author: post.author,
updatedBy: post.updatedBy,
category: post.category,
gameVersion: post.gameVersion,
ratingAverage: post.ratingAverage,
ratingCount: post.ratingCount,
commentPreview: commentPreviewByPost.get(post.id) ?? [],
commentCount: commentCountsByPost.get(post.id) ?? 0,
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
myReaction: myReactionsByPost.get(post.id) ?? null
myReaction: myReactionsByPost.get(post.id) ?? null,
myRating: myRatingsByPost.get(post.id) ?? null
};
}
@@ -2740,6 +2837,30 @@ async function lifeReactionsForPosts(
return { countsByPost, myReactionsByPost };
}
async function lifeRatingsForPosts(postIds: number[], userId: number | null): Promise<Map<number, number>> {
const myRatingsByPost = new Map<number, number>();
if (postIds.length === 0 || userId === null) {
return myRatingsByPost;
}
const rows = await query<{ postId: number; rating: number }>(
`
SELECT post_id AS "postId", rating
FROM life_post_ratings
WHERE post_id = ANY($1::integer[])
AND user_id = $2
`,
[postIds, userId]
);
for (const row of rows) {
myRatingsByPost.set(row.postId, row.rating);
}
return myRatingsByPost;
}
async function getLifeCommentById(id: number): Promise<LifeComment | null> {
const row = await queryOne<LifeCommentRow>(
`
@@ -2765,8 +2886,11 @@ async function listLifePostsWithFilters(
): Promise<LifePostsPage> {
const cursor = decodeLifePostCursor(paramsQuery.cursor);
const limit = cleanLifePostLimit(paramsQuery.limit);
const sort = cleanLifePostSort(paramsQuery.sort);
const search = asString(paramsQuery.search)?.trim();
const categoryIdValue = asString(paramsQuery.categoryId)?.trim();
const gameVersionIdValue = asString(paramsQuery.gameVersionId)?.trim();
const rateable = cleanRateableFilter(paramsQuery.rateable);
const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const params: unknown[] = [];
const conditions: string[] = ['lp.deleted_at IS NULL'];
@@ -2790,18 +2914,42 @@ async function listLifePostsWithFilters(
conditions.push(`lp.category_id = $${params.length}`);
}
if (cursor) {
params.push(cursor.createdAt, cursor.id);
conditions.push(`(lp.created_at, lp.id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
if (gameVersionIdValue && gameVersionIdValue !== 'all') {
const gameVersionId = requirePositiveInteger(gameVersionIdValue, 'server.validation.gameVersionInvalid');
params.push(gameVersionId);
conditions.push(`lp.game_version_id = $${params.length}`);
}
if (rateable !== null) {
params.push(rateable);
conditions.push(`lc.is_rateable = $${params.length}`);
}
if (cursor) {
if (sort === 'top-rated') {
params.push(cursor.ratingAverage ?? 0, cursor.createdAt, cursor.id);
conditions.push(
`(COALESCE(rating_stats.rating_average, 0), lp.created_at, lp.id) < ($${params.length - 2}::numeric, $${params.length - 1}::timestamptz, $${params.length}::integer)`
);
} else {
params.push(cursor.createdAt, cursor.id);
conditions.push(
`(lp.created_at, lp.id) ${sort === 'oldest' ? '>' : '<'} ($${params.length - 1}::timestamptz, $${params.length}::integer)`
);
}
}
const orderClause =
sort === 'top-rated'
? 'ORDER BY COALESCE(rating_stats.rating_average, 0) DESC, lp.created_at DESC, lp.id DESC'
: `ORDER BY lp.created_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, lp.id ${sort === 'oldest' ? 'ASC' : 'DESC'}`;
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit + 1);
const rows = await query<LifePostRow>(
`
${lifePostProjection(locale)}
${whereClause}
ORDER BY lp.created_at DESC, lp.id DESC
${orderClause}
LIMIT $${params.length}
`,
params
@@ -2813,9 +2961,12 @@ async function listLifePostsWithFilters(
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
const myRatingsByPost = await lifeRatingsForPosts(postIds, userId);
return {
items: posts.map((post) => hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)),
items: posts.map((post) =>
hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost)
),
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
hasMore
};
@@ -2975,9 +3126,10 @@ async function hydrateLifePostsById(
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
const myRatingsByPost = await lifeRatingsForPosts(postIds, viewerUserId);
for (const post of posts) {
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost));
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost));
}
return postById;
@@ -3223,7 +3375,8 @@ async function getLifePostById(id: number, userId: number | null = null, locale
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, false);
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, false);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
const myRatingsByPost = await lifeRatingsForPosts([post.id], userId);
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost);
}
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
@@ -3233,6 +3386,17 @@ async function ensureLifeCategory(client: DbClient, categoryId: number): Promise
}
}
async function ensureGameVersion(client: DbClient, gameVersionId: number | null): Promise<void> {
if (gameVersionId === null) {
return;
}
const result = await client.query<{ id: number }>('SELECT id FROM game_versions WHERE id = $1', [gameVersionId]);
if (result.rowCount === 0) {
throw validationError('server.validation.gameVersionInvalid');
}
}
async function replaceLifePostCategoryLink(client: DbClient, postId: number, categoryId: number): Promise<void> {
await client.query('DELETE FROM life_post_tags WHERE post_id = $1', [postId]);
await client.query(
@@ -3249,13 +3413,22 @@ export async function createLifePost(payload: Record<string, unknown>, userId: n
const id = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
await ensureGameVersion(client, cleanPayload.gameVersionId);
const result = await client.query<{ id: number }>(
`
INSERT INTO life_posts (body, category_id, ai_moderation_status, ai_moderation_language_code, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, 'reviewing', NULL, $3, $3)
INSERT INTO life_posts (
body,
category_id,
game_version_id,
ai_moderation_status,
ai_moderation_language_code,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, 'reviewing', NULL, $4, $4)
RETURNING id
`,
[cleanPayload.body, cleanPayload.categoryId, userId]
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId]
);
const createdId = result.rows[0].id;
@@ -3278,25 +3451,27 @@ export async function updateLifePost(
const updatedId = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
await ensureGameVersion(client, cleanPayload.gameVersionId);
const result = await client.query<{ id: number }>(
`
UPDATE life_posts
SET body = $1,
category_id = $2,
game_version_id = $3,
ai_moderation_status = 'reviewing',
ai_moderation_language_code = NULL,
ai_moderation_content_hash = NULL,
ai_moderation_checked_at = NULL,
ai_moderation_retry_count = 0,
ai_moderation_updated_at = now(),
updated_by_user_id = $3,
updated_by_user_id = $4,
updated_at = now()
WHERE id = $4
AND ($5 = true OR created_by_user_id = $3)
WHERE id = $5
AND ($6 = true OR created_by_user_id = $4)
AND deleted_at IS NULL
RETURNING id
`,
[cleanPayload.body, cleanPayload.categoryId, userId, id, allowAny]
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId, id, allowAny]
);
const resultId = result.rows[0]?.id ?? null;
@@ -3408,6 +3583,57 @@ export async function deleteLifePostReaction(postId: number, userId: number, loc
return getLifePostById(postId, userId, locale);
}
export async function setLifePostRating(
postId: number,
payload: Record<string, unknown>,
userId: number,
locale = defaultLocale
) {
const rating = cleanLifeRating(payload.rating);
const result = await queryOne<{ postId: number }>(
`
INSERT INTO life_post_ratings (post_id, user_id, rating)
SELECT $1, $2, $3
FROM life_posts lp
JOIN life_tags lt ON lt.id = lp.category_id
WHERE lp.id = $1
AND lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lt.is_rateable = true
ON CONFLICT (post_id, user_id)
DO UPDATE SET rating = EXCLUDED.rating, updated_at = now()
RETURNING post_id AS "postId"
`,
[postId, userId, rating]
);
return result ? getLifePostById(result.postId, userId, locale) : null;
}
export async function deleteLifePostRating(postId: number, userId: number, locale = defaultLocale) {
const result = await queryOne<{ postId: number }>(
`
DELETE FROM life_post_ratings
WHERE post_id = $1
AND user_id = $2
AND EXISTS (
SELECT 1
FROM life_posts lp
JOIN life_tags lt ON lt.id = lp.category_id
WHERE lp.id = $1
AND lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved'
AND lt.is_rateable = true
)
RETURNING post_id AS "postId"
`,
[postId, userId]
);
return result ? getLifePostById(postId, userId, locale) : null;
}
export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifeCommentPayload(payload);
@@ -3889,6 +4115,8 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, definition.table);
@@ -3908,6 +4136,14 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
columns.push('is_default');
values.push(isDefault);
}
if (definition.hasRateable) {
columns.push('is_rateable');
values.push(isRateable);
}
if (definition.hasChangeLog) {
columns.push('change_log');
values.push(changeLog);
}
columns.push('sort_order', 'created_by_user_id', 'updated_by_user_id');
values.push(sortOrder, userId, userId);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
@@ -3955,6 +4191,8 @@ export async function updateConfig(
const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const updated = await withTransaction(async (client) => {
if (definition.hasDefault && isDefault) {
@@ -3974,6 +4212,14 @@ export async function updateConfig(
values.push(isDefault);
assignments.push(`is_default = $${values.length}`);
}
if (definition.hasRateable) {
values.push(isRateable);
assignments.push(`is_rateable = $${values.length}`);
}
if (definition.hasChangeLog) {
values.push(changeLog);
assignments.push(`change_log = $${values.length}`);
}
values.push(userId);
assignments.push(`updated_by_user_id = $${values.length}`, 'updated_at = now()');
values.push(id);

View File

@@ -55,6 +55,7 @@ import {
deleteLanguage,
deleteLifeComment,
deleteLifePost,
deleteLifePostRating,
deleteLifePostReaction,
deletePokemon,
deleteRecipe,
@@ -91,6 +92,7 @@ import {
retryEntityDiscussionCommentModeration,
retryLifeCommentModeration,
retryLifePostModeration,
setLifePostRating,
setLifePostReaction,
updateConfig,
updateDailyChecklistItem,
@@ -920,6 +922,26 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
return post ? post : notFound(reply, request);
});
app.put('/api/life-posts/:id/rating', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await setLifePostRating(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.delete('/api/life-posts/:id/rating', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.ratings.set', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const post = await deleteLifePostRating(Number(id), user.id, requestLocale(request));
return post ? post : notFound(reply, request);
});
app.delete('/api/life-posts/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits(
request,

View File

@@ -44,7 +44,10 @@ export const iconReactionLike: AppIcon = 'mdi:thumb-up-outline';
export const iconReactionThanks: AppIcon = 'mdi:hand-heart-outline';
export const iconSave: AppIcon = 'mdi:content-save-outline';
export const iconSearch: AppIcon = 'mdi:magnify';
export const iconStar: AppIcon = 'mdi:star';
export const iconStarOutline: AppIcon = 'mdi:star-outline';
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
export const iconTranslate: AppIcon = 'mdi:translate';
export const iconUpload: AppIcon = 'mdi:upload-outline';
export const iconVersion: AppIcon = 'mdi:tag-outline';
export const iconWarning: AppIcon = 'mdi:alert-outline';

View File

@@ -39,6 +39,11 @@ export interface NamedEntity {
export interface LifeCategory extends NamedEntity {
isDefault: boolean;
isRateable: boolean;
}
export interface GameVersion extends NamedEntity {
changeLog: string;
}
export interface Skill extends NamedEntity {
@@ -260,7 +265,11 @@ export interface LifePost {
updatedAt: string;
author: UserSummary | null;
updatedBy: UserSummary | null;
category: NamedEntity | null;
category: (NamedEntity & { isRateable: boolean }) | null;
gameVersion: GameVersion | null;
ratingAverage: number | null;
ratingCount: number;
myRating: number | null;
commentPreview: LifeComment[];
commentCount: number;
reactionCounts: LifeReactionCounts;
@@ -279,6 +288,9 @@ export interface LifePostsParams {
search?: string;
categoryId?: string | number;
language?: string;
gameVersionId?: string | number;
rateable?: boolean | null;
sort?: 'latest' | 'oldest' | 'top-rated';
}
export interface CommentPageParams {
@@ -325,6 +337,7 @@ export interface Options {
itemTags: NamedEntity[];
maps: NamedEntity[];
lifeCategories: LifeCategory[];
gameVersions: GameVersion[];
}
export interface AuthUser {
@@ -475,7 +488,8 @@ export type ConfigType =
| 'item-usages'
| 'acquisition-methods'
| 'maps'
| 'life-tags';
| 'life-tags'
| 'game-versions';
export interface PokemonPayload {
displayId: number;
@@ -563,6 +577,7 @@ export interface DailyChecklistPayload {
export interface LifePostPayload {
body: string;
categoryId: number;
gameVersionId?: number | null;
languageCode?: string | null;
}
@@ -645,7 +660,7 @@ export interface AiModerationSettingsPayload {
clearApiKey?: boolean;
}
export function buildQuery(params: Record<string, string | number | undefined>): string {
export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {
const search = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
@@ -881,7 +896,10 @@ export const api = {
limit: params.limit,
search: params.search?.trim(),
categoryId: params.categoryId,
language: params.language
language: params.language,
gameVersionId: params.gameVersionId,
rateable: params.rateable === null ? undefined : params.rateable,
sort: params.sort
})}`
),
createLifePost: (payload: LifePostPayload) => sendJson<LifePost>('/api/life-posts', 'POST', payload),
@@ -893,6 +911,9 @@ export const api = {
setLifeReaction: (id: string | number, reactionType: LifeReactionType) =>
sendJson<LifePost>(`/api/life-posts/${id}/reaction`, 'PUT', { reactionType }),
deleteLifeReaction: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/reaction`),
setLifeRating: (id: string | number, rating: number) =>
sendJson<LifePost>(`/api/life-posts/${id}/rating`, 'PUT', { rating }),
deleteLifeRating: (id: string | number) => deleteAndGetJson<LifePost>(`/api/life-posts/${id}/rating`),
createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
@@ -949,13 +970,20 @@ export const api = {
reorderDailyChecklistItems: (ids: number[]) =>
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) =>
sendJson<Skill | LifeCategory | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (
type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) =>
sendJson<Array<Skill | LifeCategory | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean }) =>
sendJson<Skill | LifeCategory | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
sendJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: (
type: ConfigType,
id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),

View File

@@ -1727,7 +1727,7 @@ button:disabled,
}
.life-toolbar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(240px, 1.2fr) minmax(300px, 1fr) auto;
align-items: end;
gap: 16px;
}
@@ -1744,6 +1744,21 @@ button:disabled,
min-width: 0;
}
.life-toolbar__filters {
display: grid;
grid-template-columns: repeat(3, minmax(130px, 1fr));
gap: 10px;
min-width: 0;
}
.life-toolbar__select {
min-width: 0;
}
.life-toolbar__select select {
width: 100%;
}
.life-search-control {
position: relative;
}
@@ -1967,6 +1982,7 @@ button:disabled,
min-height: 30px;
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 9px;
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 38%, var(--line));
border-radius: var(--radius-small);
@@ -1977,10 +1993,98 @@ button:disabled,
line-height: 1.2;
}
.life-post__tag .ui-icon {
width: 16px;
height: 16px;
}
.life-post__tag--version {
border-color: color-mix(in srgb, var(--pokemon-yellow) 70%, var(--line));
background: color-mix(in srgb, var(--pokemon-yellow) 24%, var(--surface));
color: var(--ink-soft);
}
[data-theme="night"] .life-post__tag {
color: var(--pokemon-yellow);
}
.life-version-note {
max-width: 72ch;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
}
.life-version-note summary {
color: var(--ink-soft);
cursor: pointer;
font-size: 13px;
font-weight: 900;
}
.life-version-note p {
margin: 8px 0 0;
color: var(--muted);
font-size: 14px;
line-height: 1.55;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.life-rating {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
}
.life-rating__stars {
display: inline-flex;
align-items: center;
gap: 4px;
}
.life-rating__star {
width: 44px;
min-width: 44px;
min-height: 44px;
display: inline-grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface-soft);
color: color-mix(in srgb, var(--warning) 80%, var(--ink-soft));
cursor: pointer;
transition:
background 0.14s ease,
border-color 0.14s ease,
color 0.14s ease;
}
.life-rating__star:hover,
.life-rating__star.is-active {
border-color: color-mix(in srgb, var(--warning) 72%, var(--line));
background: color-mix(in srgb, var(--warning) 16%, var(--surface-soft));
color: color-mix(in srgb, var(--warning) 84%, var(--ink));
}
.life-rating__star:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.life-rating__star .ui-icon {
width: 22px;
height: 22px;
}
.life-rating__summary {
color: var(--muted);
font-size: 14px;
font-weight: 850;
}
.life-post__engagement {
display: flex;
flex-wrap: wrap;
@@ -5538,6 +5642,10 @@ button:disabled,
}
@media (max-width: 900px) {
.life-toolbar {
grid-template-columns: 1fr;
}
.app-shell {
display: block;
padding-top: 64px;
@@ -5832,7 +5940,8 @@ button:disabled,
}
.life-toolbar,
.life-toolbar__search {
.life-toolbar__search,
.life-toolbar__filters {
grid-template-columns: 1fr;
}

View File

@@ -37,6 +37,7 @@ import {
type AdminUser,
type ConfigType,
type DailyChecklistItem,
type GameVersion,
type Habitat,
type Item,
type Language,
@@ -70,7 +71,12 @@ type AdminTab =
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | LifeCategory) & { hasItemDrop?: boolean; isDefault?: boolean };
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
hasItemDrop?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string;
};
const adminTabIcons: Record<AdminTab, AppIcon> = {
users: iconProfile,
@@ -133,7 +139,9 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
});
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean }>>(() => [
const configTypes = computed<
Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean; supportsRateable?: boolean; supportsChangeLog?: boolean }>
>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') },
@@ -142,7 +150,8 @@ const configTypes = computed<Array<{ key: ConfigType; label: string; supportsIte
{ key: 'item-usages', label: t('config.itemUsages') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true }
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }
]);
const activeTab = ref<AdminTab>('config');
@@ -163,7 +172,15 @@ const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
const message = ref('');
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false, isDefault: false });
const configForm = ref({
id: 0,
name: '',
translations: {} as TranslationMap,
hasItemDrop: false,
isDefault: false,
isRateable: false,
changeLog: ''
});
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[] });
@@ -376,7 +393,7 @@ async function loadLanguages() {
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false };
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' };
}
function resetChecklistForm() {
@@ -441,7 +458,9 @@ function editConfig(item: EditableConfig) {
name: item.baseName ?? item.name,
translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true,
isDefault: item.isDefault === true
isDefault: item.isDefault === true,
isRateable: item.isRateable === true,
changeLog: item.changeLog ?? ''
};
configModalOpen.value = true;
}
@@ -717,7 +736,9 @@ async function saveConfig() {
name: configBaseNameForSave(),
translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
};
if (configForm.value.id) {
@@ -1281,6 +1302,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
</span>
<span class="row-actions">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
@@ -1815,6 +1837,16 @@ onMounted(() => {
{{ t('pages.admin.defaultCategory') }}
</label>
</div>
<div v-if="selectedConfig.supportsRateable" class="check-row">
<label>
<input v-model="configForm.isRateable" type="checkbox" />
{{ t('pages.admin.rateableCategory') }}
</label>
</div>
<div v-if="selectedConfig.supportsChangeLog" class="field">
<label for="config-change-log">{{ t('pages.admin.changeLog') }}</label>
<textarea id="config-change-log" v-model="configForm.changeLog"></textarea>
</div>
</form>
<template #footer>

View File

@@ -25,6 +25,9 @@ import {
iconReply,
iconSave,
iconSearch,
iconStar,
iconStarOutline,
iconVersion,
iconWarning
} from '../icons';
import {
@@ -34,6 +37,7 @@ import {
setAuthToken,
type AiModerationStatus,
type AuthUser,
type GameVersion,
type Language,
type LifeCategory,
type LifeComment,
@@ -52,9 +56,12 @@ type LifeCommentPageState = {
error: string;
};
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]);
const lifeCategories = ref<LifeCategory[]>([]);
const gameVersions = ref<GameVersion[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
@@ -65,8 +72,12 @@ const searchDraft = ref('');
const submittedSearch = ref('');
const activeCategoryId = ref('all');
const activeLanguageCode = ref('all');
const activeGameVersionId = ref('all');
const activeRateableFilter = ref('all');
const activeSort = ref<LifePostSort>('latest');
const body = ref('');
const selectedCategoryId = ref('');
const selectedGameVersionId = ref('');
const editingPostId = ref<number | null>(null);
const postModalOpen = ref(false);
const formError = ref('');
@@ -81,6 +92,8 @@ const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({});
const ratingBusyPostId = ref<number | null>(null);
const ratingErrors = ref<Record<number, string>>({});
const moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({});
const bodyInput = ref<HTMLTextAreaElement | null>(null);
@@ -99,6 +112,7 @@ const hasMorePosts = ref(false);
const loadMorePaused = ref(false);
const allCategoryValue = 'all';
const allLanguageValue = 'all';
const allGameVersionValue = 'all';
const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
@@ -114,6 +128,7 @@ function can(permissionKey: string) {
const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create'));
const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim());
@@ -124,6 +139,21 @@ const selectedFeedCategoryId = computed(() => {
const selectedFeedLanguageCode = computed(() =>
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
);
const selectedFeedGameVersionId = computed(() => {
const gameVersionId = Number(activeGameVersionId.value);
return activeGameVersionId.value === allGameVersionValue || !Number.isInteger(gameVersionId) || gameVersionId <= 0
? undefined
: gameVersionId;
});
const selectedRateableFilter = computed(() => {
if (activeRateableFilter.value === 'rateable') {
return true;
}
if (activeRateableFilter.value === 'not-rateable') {
return false;
}
return null;
});
const categoryFilterOptions = computed<TabOption[]>(() => [
{ value: allCategoryValue, label: t('pages.life.allCategories') },
...lifeCategories.value.map((category) => ({ value: String(category.id), label: category.name }))
@@ -132,6 +162,20 @@ const languageFilterOptions = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('pages.life.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name }))
]);
const gameVersionFilterOptions = computed(() => [
{ value: allGameVersionValue, label: t('pages.life.allVersions') },
...gameVersions.value.map((version) => ({ value: String(version.id), label: version.name }))
]);
const rateableFilterOptions = computed(() => [
{ value: 'all', label: t('pages.life.allRatingModes') },
{ value: 'rateable', label: t('pages.life.rateableOnly') },
{ value: 'not-rateable', label: t('pages.life.notRateableOnly') }
]);
const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() => [
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]);
const defaultLifeCategoryId = computed(() => {
const category = lifeCategories.value.find((item) => item.isDefault);
return category ? String(category.id) : '';
@@ -166,16 +210,26 @@ async function loadLifeCategories() {
try {
const options = await api.options();
lifeCategories.value = options.lifeCategories;
gameVersions.value = options.gameVersions;
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = allCategoryValue;
}
if (
activeGameVersionId.value !== allGameVersionValue &&
!gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value)
) {
activeGameVersionId.value = allGameVersionValue;
}
if (!isEditing.value && postModalOpen.value && !selectedCategoryId.value) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (!isEditing.value && selectedCategoryId.value && !lifeCategories.value.some((category) => String(category.id) === selectedCategoryId.value)) {
selectedCategoryId.value = defaultLifeCategoryId.value;
}
if (selectedGameVersionId.value && !gameVersions.value.some((gameVersion) => String(gameVersion.id) === selectedGameVersionId.value)) {
selectedGameVersionId.value = '';
}
} catch (error) {
loadError.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
}
@@ -210,7 +264,10 @@ async function loadPosts() {
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
if (requestId !== postsRequestId) {
return;
@@ -252,7 +309,10 @@ async function loadMorePosts() {
limit: lifePostPageSize,
search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value
language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value
});
if (requestId !== postsRequestId) {
return;
@@ -277,6 +337,7 @@ async function loadMorePosts() {
function resetForm() {
body.value = '';
selectedCategoryId.value = '';
selectedGameVersionId.value = '';
editingPostId.value = null;
formError.value = '';
}
@@ -285,6 +346,7 @@ function payload() {
return {
body: body.value.trim(),
categoryId: selectedLifeCategoryId() ?? 0,
gameVersionId: selectedGameVersionForPost(),
languageCode: selectedFeedLanguageCode.value ?? null
};
}
@@ -294,6 +356,11 @@ function selectedLifeCategoryId() {
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
}
function selectedGameVersionForPost() {
const gameVersionId = Number(selectedGameVersionId.value);
return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null;
}
function submitSearch() {
const nextSearch = searchDraft.value.trim();
if (nextSearch === submittedSearch.value && !loadError.value) {
@@ -326,11 +393,15 @@ function retryLoadMore() {
function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase();
const categoryId = selectedFeedCategoryId.value;
const gameVersionId = selectedFeedGameVersionId.value;
const rateable = selectedRateableFilter.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword);
const matchesCategory = categoryId === undefined || post.category?.id === categoryId;
const matchesGameVersion = gameVersionId === undefined || post.gameVersion?.id === gameVersionId;
const matchesRateable = rateable === null || post.category?.isRateable === rateable;
const matchesLanguage =
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
return matchesSearch && matchesCategory && matchesLanguage;
return matchesSearch && matchesCategory && matchesGameVersion && matchesRateable && matchesLanguage;
}
function openCreatePostModal() {
@@ -371,7 +442,9 @@ async function submitPost() {
replacePost(updated);
} else {
const created = await api.createLifePost(payload());
if (matchesCurrentFilters(created)) {
if (activeSort.value !== 'latest') {
void loadPosts();
} else if (matchesCurrentFilters(created)) {
posts.value = [created, ...posts.value];
}
}
@@ -599,6 +672,10 @@ function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId;
}
function isRatingBusy(postId: number) {
return ratingBusyPostId.value === postId;
}
function commentAuthorName(comment: LifeComment) {
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
}
@@ -632,10 +709,39 @@ function clearReactionError(postId: number) {
reactionErrors.value = nextErrors;
}
function setRatingError(postId: number, message: string) {
ratingErrors.value = { ...ratingErrors.value, [postId]: message };
}
function clearRatingError(postId: number) {
const nextErrors = { ...ratingErrors.value };
delete nextErrors[postId];
ratingErrors.value = nextErrors;
}
function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null;
}
function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
}
function ratingButtonLabel(post: LifePost, rating: number) {
return post.myRating === rating ? t('pages.life.removeRating') : t('pages.life.setRating', { count: rating });
}
function ratingAverageLabel(post: LifePost) {
if (post.ratingAverage === null || post.ratingCount === 0) {
return t('pages.life.noRatings');
}
return t('pages.life.ratingAverage', {
average: new Intl.NumberFormat(locale.value, { maximumFractionDigits: 2 }).format(post.ratingAverage),
count: post.ratingCount
});
}
function closeReactionPicker() {
reactionPickerPostId.value = null;
}
@@ -705,10 +811,32 @@ async function toggleReaction(post: LifePost, reactionType: LifeReactionType) {
}
}
async function toggleRating(post: LifePost, rating: number) {
if (!canUseRatings(post)) {
return;
}
ratingBusyPostId.value = post.id;
clearRatingError(post.id);
try {
const updatedPost = post.myRating === rating ? await api.deleteLifeRating(post.id) : await api.setLifeRating(post.id, rating);
replacePost(updatedPost);
if (activeSort.value === 'top-rated') {
void loadPosts();
}
} catch (error) {
setRatingError(post.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
} finally {
ratingBusyPostId.value = null;
}
}
function startEdit(post: LifePost) {
editingPostId.value = post.id;
body.value = post.body;
selectedCategoryId.value = post.category ? String(post.category.id) : '';
selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : '';
formError.value = '';
postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus());
@@ -899,6 +1027,15 @@ watch(activeLanguageCode, () => {
commentPages.value = {};
void loadPosts();
});
watch(activeGameVersionId, () => {
void loadPosts();
});
watch(activeRateableFilter, () => {
void loadPosts();
});
watch(activeSort, () => {
void loadPosts();
});
watch(locale, () => {
void loadLanguages();
void loadLifeCategories();
@@ -955,6 +1092,33 @@ onUnmounted(() => {
</button>
</form>
<div class="life-toolbar__filters">
<div class="field life-toolbar__select">
<label for="life-version-filter">{{ t('pages.life.versionFilter') }}</label>
<select id="life-version-filter" v-model="activeGameVersionId">
<option v-for="option in gameVersionFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field life-toolbar__select">
<label for="life-rateable-filter">{{ t('pages.life.ratingFilter') }}</label>
<select id="life-rateable-filter" v-model="activeRateableFilter">
<option v-for="option in rateableFilterOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field life-toolbar__select">
<label for="life-sort">{{ t('pages.life.sort') }}</label>
<select id="life-sort" v-model="activeSort">
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="life-toolbar__actions">
<button class="ui-button ui-button--primary" :disabled="!authReady" type="button" @click="openCreatePostModal">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
@@ -1005,6 +1169,19 @@ onUnmounted(() => {
/>
</div>
<div class="field">
<label for="life-post-version">{{ t('pages.life.gameVersion') }}</label>
<TagsSelect
id="life-post-version"
v-model="selectedGameVersionId"
:options="gameVersions"
:multiple="false"
:placeholder="t('pages.life.versionPlaceholder')"
:search-placeholder="t('pages.life.searchVersions')"
dropdown-strategy="fixed"
/>
</div>
<p v-if="formError" class="life-form__error" role="alert">{{ formError }}</p>
<div class="life-form__actions">
@@ -1096,9 +1273,37 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category" class="life-post__tags" :aria-label="t('pages.life.category')">
<span class="life-post__tag">{{ post.category.name }}</span>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')">
<span v-if="post.category" class="life-post__tag">{{ post.category.name }}</span>
<span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
{{ post.gameVersion.name }}
</span>
</div>
<details v-if="post.gameVersion?.changeLog" class="life-version-note">
<summary>{{ t('pages.life.changeLog') }}</summary>
<p>{{ post.gameVersion.changeLog }}</p>
</details>
<div v-if="post.category?.isRateable" class="life-rating">
<div class="life-rating__stars" role="group" :aria-label="t('pages.life.rating')">
<button
v-for="rating in 5"
:key="rating"
class="life-rating__star"
:class="{ 'is-active': post.myRating !== null && rating <= post.myRating }"
type="button"
:aria-label="ratingButtonLabel(post, rating)"
:aria-pressed="post.myRating === rating"
:disabled="!canUseRatings(post) || isRatingBusy(post.id)"
@click="toggleRating(post, rating)"
>
<Icon :icon="post.myRating !== null && rating <= post.myRating ? iconStar : iconStarOutline" class="ui-icon" aria-hidden="true" />
</button>
</div>
<span class="life-rating__summary">{{ ratingAverageLabel(post) }}</span>
</div>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<div class="life-post__engagement">
<div class="life-post__engagement-actions">

View File

@@ -689,9 +689,22 @@ export const systemWordingMessages = {
bodyPlaceholder: 'Share a thought, tip, or discovery...',
newPost: 'New Post',
category: 'Category',
gameVersion: 'Game version',
versionPlaceholder: 'No version',
searchVersions: 'Search versions',
languages: 'Languages',
allLanguages: 'All languages',
allCategories: 'All',
allVersions: 'All versions',
versionFilter: 'Version',
ratingFilter: 'Rating',
allRatingModes: 'All posts',
rateableOnly: 'Rateable only',
notRateableOnly: 'Not rateable',
sort: 'Sort',
sortLatest: 'Latest',
sortOldest: 'Oldest',
sortTopRated: 'Top rated',
categoryPlaceholder: 'Select category',
searchCategories: 'Search categories',
search: 'Search Life',
@@ -715,6 +728,14 @@ export const systemWordingMessages = {
reactionMenu: 'Reaction menu',
removeReaction: 'Remove reaction',
reactionFailed: 'Reaction failed',
postMeta: 'Post details',
changeLog: 'ChangeLog',
rating: 'Rating',
setRating: 'Rate {count} stars',
removeRating: 'Remove rating',
ratingAverage: '{average} average from {count} ratings',
noRatings: 'No ratings yet',
ratingFailed: 'Rating failed',
commentPlaceholder: 'Write a comment...',
commentReplyPlaceholder: 'Write a reply...',
postComment: 'Post comment',
@@ -788,6 +809,8 @@ export const systemWordingMessages = {
newConfig: 'New {name}',
editConfig: 'Edit {name}',
hasItemDrop: 'Has item drop',
rateableCategory: 'Rateable',
changeLog: 'ChangeLog',
dragSort: 'Drag to reorder: {name}',
dragSortTitle: 'Drag to reorder',
languageCode: 'Code',
@@ -860,7 +883,8 @@ export const systemWordingMessages = {
itemUsages: 'Item usages',
acquisitionMethods: 'Acquisition methods',
maps: 'Maps',
lifeCategories: 'Life categories'
lifeCategories: 'Life categories',
gameVersions: 'Game versions'
},
appearance: {
time: 'Time',
@@ -1005,9 +1029,11 @@ export const systemWordingMessages = {
postTooLong: 'Post is too long',
lifeCategoryRequired: 'Please select a category',
lifeCategoryInvalid: 'Category is invalid',
gameVersionInvalid: 'Game version is invalid',
commentRequired: 'Please enter a comment',
commentTooLong: 'Comment is too long',
reactionInvalid: 'Reaction is invalid',
ratingInvalid: 'Rating is invalid',
cursorInvalid: 'Cursor is invalid',
tagInvalid: 'Tag is invalid',
entityTypeInvalid: 'Entity type is invalid',
@@ -1731,9 +1757,22 @@ export const systemWordingMessages = {
bodyPlaceholder: '分享一段想法、心得或发现……',
newPost: 'New Post',
category: 'Category',
gameVersion: '游戏版本',
versionPlaceholder: '不选择版本',
searchVersions: '搜索版本',
languages: '语言区',
allLanguages: '全部语言',
allCategories: '全部',
allVersions: '全部版本',
versionFilter: '版本',
ratingFilter: '评分',
allRatingModes: '全部动态',
rateableOnly: '仅可评分',
notRateableOnly: '不可评分',
sort: '排序',
sortLatest: '最新',
sortOldest: '最早',
sortTopRated: '评分最高',
categoryPlaceholder: '选择 Category',
searchCategories: '搜索 Category',
search: '搜索动态',
@@ -1757,6 +1796,14 @@ export const systemWordingMessages = {
reactionMenu: '互动菜单',
removeReaction: '取消互动',
reactionFailed: '互动失败',
postMeta: '动态信息',
changeLog: 'ChangeLog',
rating: '评分',
setRating: '评 {count} 星',
removeRating: '取消评分',
ratingAverage: '{average} 平均分,{count} 人评分',
noRatings: '暂无评分',
ratingFailed: '评分失败',
commentPlaceholder: '写下评论……',
commentReplyPlaceholder: '写下回复……',
postComment: '发表评论',
@@ -1830,6 +1877,8 @@ export const systemWordingMessages = {
newConfig: '新增{name}',
editConfig: '编辑{name}',
hasItemDrop: '有掉落物',
rateableCategory: '可评分',
changeLog: 'ChangeLog',
dragSort: '拖曳排序:{name}',
dragSortTitle: '拖曳排序',
languageCode: 'Code',
@@ -1902,7 +1951,8 @@ export const systemWordingMessages = {
itemUsages: '物品用途',
acquisitionMethods: '入手方式',
maps: '地图',
lifeCategories: 'Life Categories'
lifeCategories: 'Life Categories',
gameVersions: '游戏版本'
},
appearance: {
time: '时段',
@@ -2047,9 +2097,11 @@ export const systemWordingMessages = {
postTooLong: '动态内容过长',
lifeCategoryRequired: '请选择 Category',
lifeCategoryInvalid: 'Category 不合法',
gameVersionInvalid: '游戏版本不合法',
commentRequired: '请输入评论内容',
commentTooLong: '评论内容过长',
reactionInvalid: '互动类型不合法',
ratingInvalid: '评分不合法',
cursorInvalid: '分页位置不合法',
tagInvalid: '标签不合法',
entityTypeInvalid: '实体类型不合法',