refactor(life): remove life categories and ratings

Drop life_tags and life_post_ratings tables and related schema
Remove category selection and rating UI from Life posts
Simplify Life feed filters and API endpoints
This commit is contained in:
2026-05-07 15:38:32 +08:00
parent e9d356a656
commit a781bc559b
11 changed files with 89 additions and 696 deletions

View File

@@ -64,7 +64,6 @@
- 地图 - 地图
- 栖息地 - 栖息地
- 每日 CheckList Task - 每日 CheckList Task
- Life Category
- Game Version - Game Version
- Dish Category - Dish Category
- Dish Flavor - Dish Flavor
@@ -484,13 +483,6 @@
- 名称 - 名称
- 用于栖息地中 Pokemon 出现地点。 - 用于栖息地中 Pokemon 出现地点。
### Life Category
- 名称
- 是否默认选中:最多一个 Life Category 可设为默认;新建 Life Post 时默认选中该分类。
- 是否可评分Rateable Life Category 下的 Life Post 可由用户进行 1-5 星评分。
- 用于 Life Post 分类展示和 Feed 筛选。
### Game Version ### Game Version
- 版本号 / 名称 - 版本号 / 名称
@@ -887,13 +879,11 @@ Life 是社区生活分享信息流,类似轻量社交动态。
Life Post 可配置: Life Post 可配置:
- Post 内容正文 - Post 内容正文
- Category使用 Life Category 配置,必须且只能选择 1 个
- Game Version可为空使用 Game Version 配置;有值时在 Post 卡片展示版本号。 - Game Version可为空使用 Game Version 配置;有值时在 Post 卡片展示版本号。
- 创建者、最后编辑者、创建时间、最后编辑时间 - 创建者、最后编辑者、创建时间、最后编辑时间
- 评论 - 评论
- 评论回复:仅支持回复顶层评论,不做无限嵌套 - 评论回复:仅支持回复顶层评论,不做无限嵌套
- Reactions`like``helpful``fun``thanks` - Reactions`like``helpful``fun``thanks`
- RatingsRateable Category 下的 Post 支持 1-5 星评分;每个用户每条 Post 最多一条评分,重复评分会替换原评分。
前台行为: 前台行为:
@@ -903,10 +893,9 @@ Life Post 可配置:
- 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。 - 已注册并完成邮箱验证且拥有 `life.posts.create` 权限的用户可以发布 Life Post。
- 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post删除 Life Post 使用软删除。 - 作者本人拥有 `life.posts.update` / `life.posts.delete` 权限时可以编辑、删除自己的 Life Post删除 Life Post 使用软删除。
- 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。 - 拥有 `life.posts.update-any` / `life.posts.delete-any` 权限的用户可以管理其他用户的 Life Post。
- 已注册并完成邮箱验证且拥有 `life.posts.create``life.posts.update` 权限的用户发布或编辑 Life Post 时必须选择 1 个 Life Category。
- 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post并回复顶层评论。 - 已注册并完成邮箱验证且拥有 `life.comments.create` 权限的用户可以评论 Life Post并回复顶层评论。
- 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 仅对该评论作者本人可见并保留正文,作者可通过 Undo 恢复;其他用户不可见,不显示 Deleted Comment 占位,不出现在评论列表、评论预览或评论数量中。 - 评论作者拥有 `life.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 仅对该评论作者本人可见并保留正文,作者可通过 Undo 恢复;其他用户不可见,不显示 Deleted Comment 占位,不出现在评论列表、评论预览或评论数量中。
- 已软删除的 Life Post 不出现在信息流搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。 - 已软删除的 Life Post 不出现在信息流搜索结果中,也不能继续编辑、评论或设置 Reaction。
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。 - 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
- Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。 - Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。
@@ -916,15 +905,11 @@ Life Post 可配置:
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like` - 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。 - 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。
- 已注册并完成邮箱验证且拥有 `life.ratings.set` 权限的用户可以对 Rateable Life Post 设置或取消 1-5 星评分;非 Rateable Category 下的 Post 不显示评分控件,也不能通过 API 评分。
- Life Post 展示评分时只展示平均分、评分人数和当前用户自己的评分;不展示其他用户的评分明细。
- 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。 - 支持按 Life Post 正文搜索;用户按 Enter 或点击 Search 按钮后提交搜索,不随输入实时请求;搜索结果仍按创建时间倒序展示并分页加载。
- Feed 使用 Tabs 展示 Life Category 筛选;包含 All 和后台配置的 Life Category点击 Category 后按该 Category 筛选,搜索和 Category 筛选可以同时生效。 - Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言,搜索和语言筛选可以同时生效。
- Feed 使用语言筛选展示 All languages 和启用语言;语言区筛选独立于系统 UI 语言搜索、Category 和语言筛选可以同时生效。
- Feed 支持按 Game Version 筛选All versions 表示不过滤版本。 - Feed 支持按 Game Version 筛选All versions 表示不过滤版本。
- Feed 支持 Rateable 筛选All 表示不过滤Rateable only 只展示可评分 Category 下的 Post - Feed 支持排序Latest 默认按创建时间倒序Oldest 按创建时间正序
- Feed 支持排序Latest 默认按创建时间倒序Oldest 按创建时间正序Top rated 按平均评分倒序,同分时按创建时间倒序 - 登录用户可切换 All Feed 和 Following FeedFollowing Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post并继续支持搜索、语言、Game Version 和排序筛选
- 登录用户可切换 All Feed 和 Following FeedFollowing Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post并继续支持 Life Category、语言、Game Version、Rateable 和排序筛选。
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。 - 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
- 当前没有图片上传、转发或置顶。 - 当前没有图片上传、转发或置顶。
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。 - Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
@@ -940,9 +925,7 @@ Life Post 可配置:
API 暴露边界: API 暴露边界:
- Life Post 作者信息只返回 `id``displayName` - Life Post 作者信息只返回 `id``displayName`
- Life Post Category 只返回 `id` 和按当前语言解析后的 `name`
- Life Post Game Version 只返回 `id`、展示用 `name` 和可展示 `changeLog`;未选择版本时返回 `null` - Life Post Game Version 只返回 `id`、展示用 `name` 和可展示 `changeLog`;未选择版本时返回 `null`
- Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
- Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。 - Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。
- Life Comment 作者信息只返回 `id``displayName` - Life Comment 作者信息只返回 `id``displayName`
- Life Comment 只返回 `likeCount``replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。 - Life Comment 只返回 `likeCount``replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
@@ -1224,8 +1207,8 @@ API 暴露边界:
- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。 - `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
- `GET /api/recipes/:id` - `GET /api/recipes/:id`
- `GET /api/dish` - `GET /api/dish`
- `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`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `sort``latest``oldest`
- `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。 - `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、语言、Game Version 和排序筛选。
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。 - `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
- `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit``reactionType` 筛选。 - `GET /api/life-posts/:id/reactions`:分页读取该 Life Post 的公开 Reaction 用户列表;支持 `cursor` / `limit``reactionType` 筛选。
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied` - `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied`
@@ -1333,9 +1316,6 @@ API 暴露边界:
- Life Reaction 的设置、替换和取消。 - Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction` - `PUT /api/life-posts/:id/reaction`
- `DELETE /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.*` 权限。 - 每日 CheckList 的创建、更新、删除、排序需要对应 `checklist.*` 权限。
- 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。 - 全局配置项的查看、创建、更新、删除、排序需要对应 `admin.config.*` 权限。
- 限流设置的查看和更新通过 Access 权限控制: - 限流设置的查看和更新通过 Access 权限控制:

View File

@@ -32,7 +32,6 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'maps', 'maps',
'habitats', 'habitats',
'daily-checklist-items', 'daily-checklist-items',
'life-tags',
'game-versions', 'game-versions',
'dish-categories', 'dish-categories',
'dish-flavors', 'dish-flavors',
@@ -46,6 +45,31 @@ CREATE TABLE IF NOT EXISTS entity_translations (
PRIMARY KEY (entity_type, entity_id, locale, field_name) PRIMARY KEY (entity_type, entity_id, locale, field_name)
); );
DELETE FROM entity_translations
WHERE entity_type = 'life-tags';
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check,
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
'acquisition-methods',
'items',
'ancient-artifacts',
'maps',
'habitats',
'daily-checklist-items',
'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
)
);
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, field_name, locale); ON entity_translations (entity_type, entity_id, field_name, locale);
@@ -266,7 +290,6 @@ VALUES
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true), ('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true), ('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
('life.reactions.set', 'Set Life reactions', 'Set and remove Life reactions.', '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),
('threads.create', 'Create Threads', 'Create forum threads.', 'Threads', true), ('threads.create', 'Create Threads', 'Create forum threads.', 'Threads', true),
('threads.messages.create', 'Create Thread messages', 'Create chat messages inside Threads.', 'Threads', true), ('threads.messages.create', 'Create Thread messages', 'Create chat messages inside Threads.', 'Threads', true),
('threads.follow', 'Follow Threads', 'Follow Threads and manage read state.', 'Threads', true), ('threads.follow', 'Follow Threads', 'Follow Threads and manage read state.', 'Threads', true),
@@ -288,6 +311,9 @@ ON CONFLICT (key) DO NOTHING;
DELETE FROM permissions DELETE FROM permissions
WHERE key = 'pokemon.order'; WHERE key = 'pokemon.order';
DELETE FROM permissions
WHERE key = 'life.ratings.set';
INSERT INTO roles (key, name, description, level, enabled, system_role) INSERT INTO roles (key, name, description, level, enabled, system_role)
VALUES VALUES
('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true), ('owner', 'Owner', 'Highest-level system owner with all permissions.', 1000, true, true),
@@ -377,7 +403,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete-any', 'life.comments.delete-any',
'life.comments.like', 'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set',
'threads.create', 'threads.create',
'threads.messages.create', 'threads.messages.create',
'threads.follow', 'threads.follow',
@@ -461,7 +486,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete', 'life.comments.delete',
'life.comments.like', 'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set',
'threads.create', 'threads.create',
'threads.messages.create', 'threads.messages.create',
'threads.follow', 'threads.follow',
@@ -538,7 +562,6 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.delete', 'life.comments.delete',
'life.comments.like', 'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set',
'threads.create', 'threads.create',
'threads.messages.create', 'threads.messages.create',
'threads.follow', 'threads.follow',
@@ -556,13 +579,6 @@ WHERE r.key = 'member'
) )
ON CONFLICT DO NOTHING; 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;
INSERT INTO role_permissions (role_id, permission_id) INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id SELECT r.id, p.id
FROM roles r FROM roles r
@@ -725,18 +741,6 @@ CREATE TABLE IF NOT EXISTS daily_checklist_items (
CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx
ON daily_checklist_items(sort_order, id); ON daily_checklist_items(sort_order, id);
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,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS game_versions ( CREATE TABLE IF NOT EXISTS game_versions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
@@ -754,7 +758,6 @@ CREATE INDEX IF NOT EXISTS game_versions_sort_order_idx
CREATE TABLE IF NOT EXISTS life_posts ( CREATE TABLE IF NOT EXISTS life_posts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 2000), 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, 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_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_language_code text REFERENCES languages(code) ON DELETE SET NULL,
@@ -782,14 +785,14 @@ CREATE INDEX IF NOT EXISTS life_posts_user_created_at_idx
ON life_posts(created_by_user_id, created_at DESC, id DESC) ON life_posts(created_by_user_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS life_post_tags ( DROP INDEX IF EXISTS life_posts_category_idx;
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, DROP TABLE IF EXISTS life_post_ratings;
tag_id integer NOT NULL REFERENCES life_tags(id) ON DELETE CASCADE, DROP TABLE IF EXISTS life_post_tags;
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX IF NOT EXISTS life_post_tags_tag_idx ALTER TABLE life_posts
ON life_post_tags(tag_id, post_id); DROP COLUMN IF EXISTS category_id;
DROP TABLE IF EXISTS life_tags;
CREATE TABLE IF NOT EXISTS life_post_comments ( CREATE TABLE IF NOT EXISTS life_post_comments (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
@@ -847,21 +850,6 @@ CREATE INDEX IF NOT EXISTS life_post_reactions_post_idx
CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx CREATE INDEX IF NOT EXISTS life_post_reactions_user_idx
ON life_post_reactions(user_id, updated_at DESC, post_id DESC); 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 thread_channels ( CREATE TABLE IF NOT EXISTS thread_channels (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
@@ -1344,8 +1332,6 @@ CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sor
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item); CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS life_tags_single_default_idx ON life_tags(is_default) WHERE is_default = true;
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id); CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id); CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id); CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
@@ -1506,10 +1492,6 @@ CREATE TABLE IF NOT EXISTS notification_ws_tickets (
CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
ON notification_ws_tickets(user_id, expires_at DESC); ON notification_ws_tickets(user_id, expires_at DESC);
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 CREATE INDEX IF NOT EXISTS life_posts_game_version_idx
ON life_posts(game_version_id, created_at DESC, id DESC) ON life_posts(game_version_id, created_at DESC, id DESC)
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;

View File

@@ -136,7 +136,6 @@ type EntityType =
| 'maps' | 'maps'
| 'habitats' | 'habitats'
| 'daily-checklist-items' | 'daily-checklist-items'
| 'life-tags'
| 'game-versions' | 'game-versions'
| 'dish-categories' | 'dish-categories'
| 'dish-flavors' | 'dish-flavors'
@@ -149,7 +148,6 @@ type ConfigType =
| 'favorite-things' | 'favorite-things'
| 'acquisition-methods' | 'acquisition-methods'
| 'maps' | 'maps'
| 'life-tags'
| 'game-versions' | 'game-versions'
| 'dish-flavors'; | 'dish-flavors';
@@ -158,8 +156,6 @@ type ConfigDefinition = {
entityType: EntityType; entityType: EntityType;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
hasDefault?: boolean;
hasRateable?: boolean;
hasChangeLog?: boolean; hasChangeLog?: boolean;
}; };
type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats'; type SortableContentType = 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
@@ -330,7 +326,6 @@ type DailyChecklistPayload = {
type LifePostPayload = { type LifePostPayload = {
body: string; body: string;
categoryId: number;
gameVersionId: number | null; gameVersionId: number | null;
languageCode: string | null; languageCode: string | null;
}; };
@@ -427,10 +422,7 @@ type LifePostRow = {
updatedAt: Date; updatedAt: Date;
author: { id: number; displayName: string } | null; author: { id: number; displayName: string } | null;
updatedBy: { id: number; displayName: string } | null; updatedBy: { id: number; displayName: string } | null;
category: { id: number; name: string; isRateable: boolean } | null;
gameVersion: { id: number; name: string; changeLog: string } | null; gameVersion: { id: number; name: string; changeLog: string } | null;
ratingAverage: number | null;
ratingCount: number;
}; };
type LifePost = Omit<LifePostRow, 'createdAtCursor'> & { type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
@@ -438,16 +430,14 @@ type LifePost = Omit<LifePostRow, 'createdAtCursor'> & {
commentCount: number; commentCount: number;
reactionCounts: LifeReactionCounts; reactionCounts: LifeReactionCounts;
myReaction: LifeReactionType | null; myReaction: LifeReactionType | null;
myRating: number | null;
}; };
type LifePostCursor = { type LifePostCursor = {
createdAt: string; createdAt: string;
id: number; id: number;
ratingAverage?: number;
}; };
type LifePostSort = 'latest' | 'oldest' | 'top-rated'; type LifePostSort = 'latest' | 'oldest';
type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied'; type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
type CommentCursor = { type CommentCursor = {
createdAt: string; createdAt: string;
@@ -668,8 +658,6 @@ type ConfigChangeSource = {
name: string; name: string;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string; changeLog?: string;
} & TranslationChangeSource; } & TranslationChangeSource;
@@ -738,7 +726,6 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' }, 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' }, maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }, 'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true },
'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' } 'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' }
}; };
@@ -1081,13 +1068,6 @@ function systemListJsonSql(expression: string, options: readonly SystemListOptio
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`; return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
} }
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", 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 }>> { function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: string; changeLog: string }>> {
const name = localizedName('game-versions', 'gv', locale); 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')}`); return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
@@ -1136,12 +1116,6 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
if (definition.hasTrading) { if (definition.hasTrading) {
columns.push(`c.has_trading AS "hasTrading"`); columns.push(`c.has_trading AS "hasTrading"`);
} }
if (definition.hasDefault) {
columns.push(`c.is_default AS "isDefault"`);
}
if (definition.hasRateable) {
columns.push(`c.is_rateable AS "isRateable"`);
}
if (definition.hasChangeLog) { if (definition.hasChangeLog) {
columns.push(`c.change_log AS "changeLog"`); columns.push(`c.change_log AS "changeLog"`);
} }
@@ -2096,7 +2070,7 @@ async function ensurePokemonTypeCatalog(
const changes = configEditChanges( const changes = configEditChanges(
{ table: 'pokemon_types', entityType: 'pokemon-types' }, { table: 'pokemon_types', entityType: 'pokemon-types' },
existing.rows[0], existing.rows[0],
{ name, translations, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' } { name, translations, hasItemDrop: false, hasTrading: false, changeLog: '' }
); );
if (changes.length) { if (changes.length) {
await client.query( await client.query(
@@ -2665,8 +2639,6 @@ function configEditChanges(
translations: TranslationInput; translations: TranslationInput;
hasItemDrop: boolean; hasItemDrop: boolean;
hasTrading: boolean; hasTrading: boolean;
isDefault: boolean;
isRateable: boolean;
changeLog: string; changeLog: string;
} }
): EditChange[] { ): EditChange[] {
@@ -2679,12 +2651,6 @@ function configEditChanges(
if (definition.hasTrading) { if (definition.hasTrading) {
pushChange(changes, 'Has trading', boolValue(Boolean(before.hasTrading)), boolValue(after.hasTrading)); pushChange(changes, 'Has trading', boolValue(Boolean(before.hasTrading)), boolValue(after.hasTrading));
} }
if (definition.hasDefault) {
pushChange(changes, 'Default category', boolValue(Boolean(before.isDefault)), boolValue(after.isDefault));
}
if (definition.hasRateable) {
pushChange(changes, 'Rateable', boolValue(Boolean(before.isRateable)), boolValue(after.isRateable));
}
if (definition.hasChangeLog) { if (definition.hasChangeLog) {
pushChange(changes, 'ChangeLog', before.changeLog, after.changeLog); pushChange(changes, 'ChangeLog', before.changeLog, after.changeLog);
} }
@@ -2782,7 +2748,6 @@ export async function getOptions(locale = defaultLocale) {
favoriteThings, favoriteThings,
acquisitionMethods, acquisitionMethods,
maps, maps,
lifeCategories,
gameVersions, gameVersions,
dishFlavors dishFlavors
] = await Promise.all([ ] = await Promise.all([
@@ -2792,7 +2757,6 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('favorite_things', 'favorite-things', locale), optionSelect('favorite_things', 'favorite-things', locale),
optionSelect('acquisition_methods', 'acquisition-methods', locale), optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale), optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale),
gameVersionOptions(locale), gameVersionOptions(locale),
optionSelect('dish_flavors', 'dish-flavors', locale) optionSelect('dish_flavors', 'dish-flavors', locale)
]); ]);
@@ -2808,7 +2772,6 @@ export async function getOptions(locale = defaultLocale) {
acquisitionMethods, acquisitionMethods,
itemTags: favoriteThings, itemTags: favoriteThings,
maps, maps,
lifeCategories,
gameVersions, gameVersions,
dishFlavors dishFlavors
}; };
@@ -2851,8 +2814,6 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
const recipeItemName = localizedName('items', 'result_item', locale); const recipeItemName = localizedName('items', 'result_item', locale);
const recipeMaterialName = localizedName('items', 'material_item', locale); const recipeMaterialName = localizedName('items', 'material_item', locale);
const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale); const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
const lifeCategoryName = localizedName('life-tags', 'lc', locale);
const [pokemon, habitats, items, artifacts, recipes, checklist, life, users] = await Promise.all([ const [pokemon, habitats, items, artifacts, recipes, checklist, life, users] = await Promise.all([
query<GlobalSearchItem>( query<GlobalSearchItem>(
` `
@@ -2981,10 +2942,9 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau
LEFT(lp.body, 120) AS title, LEFT(lp.body, 120) AS title,
'/life/' || lp.id AS url, '/life/' || lp.id AS url,
NULL AS summary, NULL AS summary,
${lifeCategoryName} AS meta, NULL AS meta,
NULL AS image NULL AS image
FROM life_posts lp FROM life_posts lp
LEFT JOIN life_tags lc ON lc.id = lp.category_id
WHERE lp.deleted_at IS NULL WHERE lp.deleted_at IS NULL
AND lp.ai_moderation_status = 'approved' AND lp.ai_moderation_status = 'approved'
AND lp.body ILIKE $1 AND lp.body ILIKE $1
@@ -3153,12 +3113,10 @@ function cleanLifePostPayload(payload: Record<string, unknown>): LifePostPayload
if (body.length > 2000) { if (body.length > 2000) {
throw validationError('server.validation.postTooLong'); throw validationError('server.validation.postTooLong');
} }
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.lifeCategoryRequired');
const gameVersionId = optionalPositiveInteger(payload.gameVersionId, 'server.validation.gameVersionInvalid'); const gameVersionId = optionalPositiveInteger(payload.gameVersionId, 'server.validation.gameVersionInvalid');
return { return {
body, body,
categoryId,
gameVersionId, gameVersionId,
languageCode: cleanModerationLanguageCode(payload.languageCode) languageCode: cleanModerationLanguageCode(payload.languageCode)
}; };
@@ -3203,15 +3161,6 @@ function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
return cleanLifeReactionType(reactionType); 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 { function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
const source = asString(value); const source = asString(value);
if (!source) { if (!source) {
@@ -3279,7 +3228,6 @@ function addModerationLanguageCondition(
} }
function lifePostProjection(locale = defaultLocale): string { function lifePostProjection(locale = defaultLocale): string {
const categoryName = localizedName('life-tags', 'lc', locale);
const gameVersionName = localizedName('game-versions', 'gv', locale); const gameVersionName = localizedName('game-versions', 'gv', locale);
return ` return `
@@ -3300,29 +3248,12 @@ function lifePostProjection(locale = defaultLocale): string {
WHEN updated_user.id IS NULL THEN NULL WHEN updated_user.id IS NULL THEN NULL
ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name) ELSE json_build_object('id', updated_user.id, 'displayName', updated_user.display_name)
END AS "updatedBy", END AS "updatedBy",
CASE
WHEN lc.id IS NULL THEN NULL
ELSE json_build_object('id', lc.id, 'name', ${categoryName}, 'isRateable', lc.is_rateable)
END AS category,
CASE CASE
WHEN gv.id IS NULL THEN NULL WHEN gv.id IS NULL THEN NULL
ELSE json_build_object('id', gv.id, 'name', ${gameVersionName}, 'changeLog', gv.change_log) ELSE json_build_object('id', gv.id, 'name', ${gameVersionName}, 'changeLog', gv.change_log)
END AS "gameVersion", 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 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 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 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 LEFT JOIN users updated_user ON updated_user.id = lp.updated_by_user_id
`; `;
@@ -3340,18 +3271,7 @@ function cleanLifePostLimit(value: QueryValue): number {
function cleanLifePostSort(value: QueryValue): LifePostSort { function cleanLifePostSort(value: QueryValue): LifePostSort {
const sort = asString(value); const sort = asString(value);
return sort === 'oldest' || sort === 'top-rated' ? sort : 'latest'; return sort === 'oldest' ? 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 { function cleanCommentLimit(value: QueryValue): number {
@@ -3460,19 +3380,17 @@ function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>; const cursor = JSON.parse(Buffer.from(rawCursor, 'base64url').toString('utf8')) as Partial<LifePostCursor>;
const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : ''; const createdAt = typeof cursor.createdAt === 'string' ? cursor.createdAt : '';
const id = Number(cursor.id); const id = Number(cursor.id);
const ratingAverage = cursor.ratingAverage === undefined ? undefined : Number(cursor.ratingAverage);
if ( if (
!createdAt || !createdAt ||
Number.isNaN(new Date(createdAt).getTime()) || Number.isNaN(new Date(createdAt).getTime()) ||
!Number.isInteger(id) || !Number.isInteger(id) ||
id <= 0 || id <= 0
(ratingAverage !== undefined && (Number.isNaN(ratingAverage) || ratingAverage < 0))
) { ) {
throw validationError('server.validation.cursorInvalid'); throw validationError('server.validation.cursorInvalid');
} }
return { createdAt, id, ratingAverage }; return { createdAt, id };
} catch (error) { } catch (error) {
if (error instanceof Error && 'statusCode' in error) { if (error instanceof Error && 'statusCode' in error) {
throw error; throw error;
@@ -3482,10 +3400,7 @@ function decodeLifePostCursor(value: QueryValue): LifePostCursor | null {
} }
function encodeLifePostCursor(post: LifePostRow): string { function encodeLifePostCursor(post: LifePostRow): string {
return Buffer.from( return Buffer.from(JSON.stringify({ createdAt: post.createdAtCursor, id: post.id }), 'utf8').toString('base64url');
JSON.stringify({ createdAt: post.createdAtCursor, id: post.id, ratingAverage: post.ratingAverage ?? 0 }),
'utf8'
).toString('base64url');
} }
function encodeProfileCursor(cursor: LifePostCursor): string { function encodeProfileCursor(cursor: LifePostCursor): string {
@@ -3560,8 +3475,7 @@ function hydrateLifePost(
commentPreviewByPost: Map<number, LifeComment[]>, commentPreviewByPost: Map<number, LifeComment[]>,
commentCountsByPost: Map<number, number>, commentCountsByPost: Map<number, number>,
countsByPost: Map<number, LifeReactionCounts>, countsByPost: Map<number, LifeReactionCounts>,
myReactionsByPost: Map<number, LifeReactionType>, myReactionsByPost: Map<number, LifeReactionType>
myRatingsByPost: Map<number, number>
): LifePost { ): LifePost {
return { return {
id: post.id, id: post.id,
@@ -3573,15 +3487,11 @@ function hydrateLifePost(
updatedAt: post.updatedAt, updatedAt: post.updatedAt,
author: post.author, author: post.author,
updatedBy: post.updatedBy, updatedBy: post.updatedBy,
category: post.category,
gameVersion: post.gameVersion, gameVersion: post.gameVersion,
ratingAverage: post.ratingAverage,
ratingCount: post.ratingCount,
commentPreview: commentPreviewByPost.get(post.id) ?? [], commentPreview: commentPreviewByPost.get(post.id) ?? [],
commentCount: commentCountsByPost.get(post.id) ?? 0, commentCount: commentCountsByPost.get(post.id) ?? 0,
reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(), reactionCounts: countsByPost.get(post.id) ?? emptyLifeReactionCounts(),
myReaction: myReactionsByPost.get(post.id) ?? null, myReaction: myReactionsByPost.get(post.id) ?? null
myRating: myRatingsByPost.get(post.id) ?? null
}; };
} }
@@ -4004,30 +3914,6 @@ export async function listLifePostReactionUsers(
}; };
} }
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, userId: number | null = null, canViewAll = false): Promise<LifeComment | null> { async function getLifeCommentById(id: number, userId: number | null = null, canViewAll = false): Promise<LifeComment | null> {
const row = await queryOne<LifeCommentRow>( const row = await queryOne<LifeCommentRow>(
` `
@@ -4055,9 +3941,7 @@ async function listLifePostsWithFilters(
const limit = cleanLifePostLimit(paramsQuery.limit); const limit = cleanLifePostLimit(paramsQuery.limit);
const sort = cleanLifePostSort(paramsQuery.sort); const sort = cleanLifePostSort(paramsQuery.sort);
const search = asString(paramsQuery.search)?.trim(); const search = asString(paramsQuery.search)?.trim();
const categoryIdValue = asString(paramsQuery.categoryId)?.trim();
const gameVersionIdValue = asString(paramsQuery.gameVersionId)?.trim(); const gameVersionIdValue = asString(paramsQuery.gameVersionId)?.trim();
const rateable = cleanRateableFilter(paramsQuery.rateable);
const languageCode = cleanModerationLanguageFilter(paramsQuery.language); const languageCode = cleanModerationLanguageFilter(paramsQuery.language);
const params: unknown[] = []; const params: unknown[] = [];
const conditions: string[] = ['lp.deleted_at IS NULL']; const conditions: string[] = ['lp.deleted_at IS NULL'];
@@ -4087,41 +3971,20 @@ async function listLifePostsWithFilters(
conditions.push(`lp.body ILIKE $${params.length}`); conditions.push(`lp.body ILIKE $${params.length}`);
} }
if (categoryIdValue) {
const categoryId = requirePositiveInteger(categoryIdValue, 'server.validation.lifeCategoryInvalid');
params.push(categoryId);
conditions.push(`lp.category_id = $${params.length}`);
}
if (gameVersionIdValue && gameVersionIdValue !== 'all') { if (gameVersionIdValue && gameVersionIdValue !== 'all') {
const gameVersionId = requirePositiveInteger(gameVersionIdValue, 'server.validation.gameVersionInvalid'); const gameVersionId = requirePositiveInteger(gameVersionIdValue, 'server.validation.gameVersionInvalid');
params.push(gameVersionId); params.push(gameVersionId);
conditions.push(`lp.game_version_id = $${params.length}`); conditions.push(`lp.game_version_id = $${params.length}`);
} }
if (rateable !== null) {
params.push(rateable);
conditions.push(`lc.is_rateable = $${params.length}`);
}
if (cursor) { if (cursor) {
if (sort === 'top-rated') { params.push(cursor.createdAt, cursor.id);
params.push(cursor.ratingAverage ?? 0, cursor.createdAt, cursor.id); conditions.push(
conditions.push( `(lp.created_at, lp.id) ${sort === 'oldest' ? '>' : '<'} ($${params.length - 1}::timestamptz, $${params.length}::integer)`
`(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 = const orderClause = `ORDER BY lp.created_at ${sort === 'oldest' ? 'ASC' : 'DESC'}, lp.id ${sort === 'oldest' ? 'ASC' : 'DESC'}`;
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 ')}` : ''; const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
params.push(limit + 1); params.push(limit + 1);
const rows = await query<LifePostRow>( const rows = await query<LifePostRow>(
@@ -4140,11 +4003,10 @@ async function listLifePostsWithFilters(
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll); const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, userId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll); const commentCountsByPost = await lifeCommentCountsForPosts(postIds, userId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, userId);
const myRatingsByPost = await lifeRatingsForPosts(postIds, userId);
return { return {
items: posts.map((post) => items: posts.map((post) =>
hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost) hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost)
), ),
nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null, nextCursor: hasMore && posts.length > 0 ? encodeLifePostCursor(posts[posts.length - 1]) : null,
hasMore hasMore
@@ -4429,10 +4291,9 @@ async function hydrateLifePostsById(
const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll); const commentPreviewByPost = await lifeCommentPreviewForPosts(postIds, viewerUserId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll); const commentCountsByPost = await lifeCommentCountsForPosts(postIds, viewerUserId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts(postIds, viewerUserId);
const myRatingsByPost = await lifeRatingsForPosts(postIds, viewerUserId);
for (const post of posts) { for (const post of posts) {
postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost)); postById.set(post.id, hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost));
} }
return postById; return postById;
@@ -4693,8 +4554,7 @@ async function getLifePostById(
const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, canViewAll); const commentPreviewByPost = await lifeCommentPreviewForPosts([post.id], userId, canViewAll);
const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, canViewAll); const commentCountsByPost = await lifeCommentCountsForPosts([post.id], userId, canViewAll);
const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId); const { countsByPost, myReactionsByPost } = await lifeReactionsForPosts([post.id], userId);
const myRatingsByPost = await lifeRatingsForPosts([post.id], userId); return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost);
return hydrateLifePost(post, commentPreviewByPost, commentCountsByPost, countsByPost, myReactionsByPost, myRatingsByPost);
} }
export async function getLifePost( export async function getLifePost(
@@ -4707,13 +4567,6 @@ export async function getLifePost(
return getLifePostById(id, userId, locale, { enforceVisibility: true, canViewAll }); return getLifePostById(id, userId, locale, { enforceVisibility: true, canViewAll });
} }
async function ensureLifeCategory(client: DbClient, categoryId: number): Promise<void> {
const result = await client.query<{ id: number }>('SELECT id FROM life_tags WHERE id = $1', [categoryId]);
if (result.rowCount === 0) {
throw validationError('server.validation.lifeCategoryInvalid');
}
}
async function ensureGameVersion(client: DbClient, gameVersionId: number | null): Promise<void> { async function ensureGameVersion(client: DbClient, gameVersionId: number | null): Promise<void> {
if (gameVersionId === null) { if (gameVersionId === null) {
return; return;
@@ -4725,43 +4578,28 @@ async function ensureGameVersion(client: DbClient, gameVersionId: number | null)
} }
} }
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(
`
INSERT INTO life_post_tags (post_id, tag_id)
VALUES ($1, $2)
`,
[postId, categoryId]
);
}
export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) { export async function createLifePost(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanLifePostPayload(payload); const cleanPayload = cleanLifePostPayload(payload);
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
await ensureGameVersion(client, cleanPayload.gameVersionId); await ensureGameVersion(client, cleanPayload.gameVersionId);
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
` `
INSERT INTO life_posts ( INSERT INTO life_posts (
body, body,
category_id,
game_version_id, game_version_id,
ai_moderation_status, ai_moderation_status,
ai_moderation_language_code, ai_moderation_language_code,
created_by_user_id, created_by_user_id,
updated_by_user_id updated_by_user_id
) )
VALUES ($1, $2, $3, 'reviewing', NULL, $4, $4) VALUES ($1, $2, 'reviewing', NULL, $3, $3)
RETURNING id RETURNING id
`, `,
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId] [cleanPayload.body, cleanPayload.gameVersionId, userId]
); );
const createdId = result.rows[0].id; return result.rows[0].id;
await replaceLifePostCategoryLink(client, createdId, cleanPayload.categoryId);
return createdId;
}); });
await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true }); await requestAiModerationReview({ type: 'life-post', id }, { languageCode: cleanPayload.languageCode, resetRetries: true });
@@ -4778,37 +4616,29 @@ export async function updateLifePost(
const cleanPayload = cleanLifePostPayload(payload); const cleanPayload = cleanLifePostPayload(payload);
const updatedId = await withTransaction(async (client) => { const updatedId = await withTransaction(async (client) => {
await ensureLifeCategory(client, cleanPayload.categoryId);
await ensureGameVersion(client, cleanPayload.gameVersionId); await ensureGameVersion(client, cleanPayload.gameVersionId);
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
` `
UPDATE life_posts UPDATE life_posts
SET body = $1, SET body = $1,
category_id = $2, game_version_id = $2,
game_version_id = $3,
ai_moderation_status = 'reviewing', ai_moderation_status = 'reviewing',
ai_moderation_language_code = NULL, ai_moderation_language_code = NULL,
ai_moderation_content_hash = NULL, ai_moderation_content_hash = NULL,
ai_moderation_checked_at = NULL, ai_moderation_checked_at = NULL,
ai_moderation_retry_count = 0, ai_moderation_retry_count = 0,
ai_moderation_updated_at = now(), ai_moderation_updated_at = now(),
updated_by_user_id = $4, updated_by_user_id = $3,
updated_at = now() updated_at = now()
WHERE id = $5 WHERE id = $4
AND ($6 = true OR created_by_user_id = $4) AND ($5 = true OR created_by_user_id = $3)
AND deleted_at IS NULL AND deleted_at IS NULL
RETURNING id RETURNING id
`, `,
[cleanPayload.body, cleanPayload.categoryId, cleanPayload.gameVersionId, userId, id, allowAny] [cleanPayload.body, cleanPayload.gameVersionId, userId, id, allowAny]
); );
const resultId = result.rows[0]?.id ?? null; return result.rows[0]?.id ?? null;
if (resultId === null) {
return null;
}
await replaceLifePostCategoryLink(client, resultId, cleanPayload.categoryId);
return resultId;
}); });
if (updatedId) { if (updatedId) {
@@ -4916,57 +4746,6 @@ export async function deleteLifePostReaction(postId: number, userId: number, loc
return getLifePostById(postId, userId, locale); 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) { export async function createLifeComment(postId: number, payload: Record<string, unknown>, userId: number) {
const cleanPayload = cleanLifeCommentPayload(payload); const cleanPayload = cleanLifeCommentPayload(payload);
@@ -5609,18 +5388,10 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false; const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : 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 changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const sortOrder = await nextSortOrder(client, definition.table); const sortOrder = await nextSortOrder(client, definition.table);
if (definition.hasDefault && isDefault) {
await client.query(
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $1, updated_at = now() WHERE is_default = true`,
[userId]
);
}
const columns = ['name']; const columns = ['name'];
const values: unknown[] = [name]; const values: unknown[] = [name];
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
@@ -5631,14 +5402,6 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
columns.push('has_trading'); columns.push('has_trading');
values.push(hasTrading); values.push(hasTrading);
} }
if (definition.hasDefault) {
columns.push('is_default');
values.push(isDefault);
}
if (definition.hasRateable) {
columns.push('is_rateable');
values.push(isRateable);
}
if (definition.hasChangeLog) { if (definition.hasChangeLog) {
columns.push('change_log'); columns.push('change_log');
values.push(changeLog); values.push(changeLog);
@@ -5690,19 +5453,10 @@ export async function updateConfig(
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false; const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : 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 changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
const before = await getConfigById(type, id, defaultLocale); const before = await getConfigById(type, id, defaultLocale);
const updated = await withTransaction(async (client) => { const updated = await withTransaction(async (client) => {
if (definition.hasDefault && isDefault) {
await client.query(
`UPDATE ${definition.table} SET is_default = false, updated_by_user_id = $2, updated_at = now() WHERE id <> $1 AND is_default = true`,
[id, userId]
);
}
const assignments = ['name = $1']; const assignments = ['name = $1'];
const values: unknown[] = [name]; const values: unknown[] = [name];
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
@@ -5713,14 +5467,6 @@ export async function updateConfig(
values.push(hasTrading); values.push(hasTrading);
assignments.push(`has_trading = $${values.length}`); assignments.push(`has_trading = $${values.length}`);
} }
if (definition.hasDefault) {
values.push(isDefault);
assignments.push(`is_default = $${values.length}`);
}
if (definition.hasRateable) {
values.push(isRateable);
assignments.push(`is_rateable = $${values.length}`);
}
if (definition.hasChangeLog) { if (definition.hasChangeLog) {
values.push(changeLog); values.push(changeLog);
assignments.push(`change_log = $${values.length}`); assignments.push(`change_log = $${values.length}`);
@@ -5768,7 +5514,7 @@ export async function updateConfig(
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']); await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
const changes = before const changes = before
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, hasTrading, isDefault, isRateable, changeLog }) ? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, hasTrading, changeLog })
: []; : [];
await recordEditLog(client, type, id, 'update', userId, changes); await recordEditLog(client, type, id, 'update', userId, changes);
return true; return true;

View File

@@ -68,7 +68,6 @@ import {
deleteLifeComment, deleteLifeComment,
deleteLifeCommentLike, deleteLifeCommentLike,
deleteLifePost, deleteLifePost,
deleteLifePostRating,
deleteLifePostReaction, deleteLifePostReaction,
deletePokemon, deletePokemon,
deleteRecipe, deleteRecipe,
@@ -133,7 +132,6 @@ import {
retryLifePostModeration, retryLifePostModeration,
retryThreadMessageModeration, retryThreadMessageModeration,
restoreLifeComment, restoreLifeComment,
setLifePostRating,
setLifePostReaction, setLifePostReaction,
setEntityDiscussionCommentLike, setEntityDiscussionCommentLike,
setLifeCommentLike, setLifeCommentLike,
@@ -1502,26 +1500,6 @@ app.delete('/api/life-posts/:id/reaction', async (request, reply) => {
return post ? post : notFound(reply, request); 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) => { app.delete('/api/life-posts/:id', async (request, reply) => {
const user = await requireAnyPermissionWithRateLimits( const user = await requireAnyPermissionWithRateLimits(
request, request,

View File

@@ -85,10 +85,6 @@ const changeLabelKeys: Record<string, string> = {
有掉落物: 'pages.admin.hasItemDrop', 有掉落物: 'pages.admin.hasItemDrop',
'Has trading': 'pages.admin.hasTrading', 'Has trading': 'pages.admin.hasTrading',
'有 Trading': 'pages.admin.hasTrading', '有 Trading': 'pages.admin.hasTrading',
'Default category': 'pages.admin.defaultCategory',
默认分类: 'pages.admin.defaultCategory',
Rateable: 'pages.admin.rateableCategory',
可评分: 'pages.admin.rateableCategory',
ChangeLog: 'pages.admin.changeLog' ChangeLog: 'pages.admin.changeLog'
}; };

View File

@@ -74,11 +74,6 @@ export interface NamedEntity {
translations?: TranslationMap; translations?: TranslationMap;
} }
export interface LifeCategory extends NamedEntity {
isDefault: boolean;
isRateable: boolean;
}
export interface GameVersion extends NamedEntity { export interface GameVersion extends NamedEntity {
changeLog: string; changeLog: string;
} }
@@ -494,11 +489,7 @@ export interface LifePost {
updatedAt: string; updatedAt: string;
author: UserSummary | null; author: UserSummary | null;
updatedBy: UserSummary | null; updatedBy: UserSummary | null;
category: (NamedEntity & { isRateable: boolean }) | null;
gameVersion: GameVersion | null; gameVersion: GameVersion | null;
ratingAverage: number | null;
ratingCount: number;
myRating: number | null;
commentPreview: LifeComment[]; commentPreview: LifeComment[];
commentCount: number; commentCount: number;
reactionCounts: LifeReactionCounts; reactionCounts: LifeReactionCounts;
@@ -515,11 +506,9 @@ export interface LifePostsParams {
cursor?: string | null; cursor?: string | null;
limit?: number; limit?: number;
search?: string; search?: string;
categoryId?: string | number;
language?: string; language?: string;
gameVersionId?: string | number; gameVersionId?: string | number;
rateable?: boolean | null; sort?: 'latest' | 'oldest';
sort?: 'latest' | 'oldest' | 'top-rated';
} }
export interface CommentPageParams { export interface CommentPageParams {
@@ -775,7 +764,6 @@ export interface Options {
acquisitionMethods: NamedEntity[]; acquisitionMethods: NamedEntity[];
itemTags: NamedEntity[]; itemTags: NamedEntity[];
maps: NamedEntity[]; maps: NamedEntity[];
lifeCategories: LifeCategory[];
gameVersions: GameVersion[]; gameVersions: GameVersion[];
dishFlavors: NamedEntity[]; dishFlavors: NamedEntity[];
} }
@@ -935,7 +923,6 @@ export type ConfigType =
| 'favorite-things' | 'favorite-things'
| 'acquisition-methods' | 'acquisition-methods'
| 'maps' | 'maps'
| 'life-tags'
| 'game-versions' | 'game-versions'
| 'dish-flavors'; | 'dish-flavors';
@@ -1059,7 +1046,6 @@ export interface DailyChecklistPayload {
export interface LifePostPayload { export interface LifePostPayload {
body: string; body: string;
categoryId: number;
gameVersionId?: number | null; gameVersionId?: number | null;
languageCode?: string | null; languageCode?: string | null;
} }
@@ -1395,10 +1381,8 @@ export const api = {
cursor: params.cursor ?? undefined, cursor: params.cursor ?? undefined,
limit: params.limit, limit: params.limit,
search: params.search, search: params.search,
categoryId: params.categoryId,
language: params.language, language: params.language,
gameVersionId: params.gameVersionId, gameVersionId: params.gameVersionId,
rateable: params.rateable === null ? undefined : params.rateable,
sort: params.sort sort: params.sort
})}` })}`
), ),
@@ -1456,10 +1440,8 @@ export const api = {
cursor: params.cursor ?? undefined, cursor: params.cursor ?? undefined,
limit: params.limit, limit: params.limit,
search: params.search?.trim(), search: params.search?.trim(),
categoryId: params.categoryId,
language: params.language, language: params.language,
gameVersionId: params.gameVersionId, gameVersionId: params.gameVersionId,
rateable: params.rateable === null ? undefined : params.rateable,
sort: params.sort sort: params.sort
})}` })}`
), ),
@@ -1531,9 +1513,6 @@ export const api = {
sendJson<ThreadSummary>(`/api/admin/threads/${id}/lock`, 'PUT', { locked }), sendJson<ThreadSummary>(`/api/admin/threads/${id}/lock`, 'PUT', { locked }),
deleteThread: (id: string | number) => deleteJson(`/api/admin/threads/${id}`), deleteThread: (id: string | number) => deleteJson(`/api/admin/threads/${id}`),
deleteThreadMessage: (id: string | number) => deleteJson(`/api/admin/thread-messages/${id}`), deleteThreadMessage: (id: string | number) => deleteJson(`/api/admin/thread-messages/${id}`),
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) => createLifeComment: (postId: string | number, payload: LifeCommentPayload) =>
sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload), sendJson<LifeComment>(`/api/life-posts/${postId}/comments`, 'POST', payload),
lifeComments: (postId: string | number, params: CommentPageParams = {}) => lifeComments: (postId: string | number, params: CommentPageParams = {}) =>
@@ -1599,20 +1578,20 @@ export const api = {
reorderDailyChecklistItems: (ids: number[]) => reorderDailyChecklistItems: (ids: number[]) =>
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }), sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`), deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`), config: (type: ConfigType) => getJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: ( createConfig: (
type: ConfigType, type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string } payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
) => ) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload), sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) => reorderConfig: (type: ConfigType, ids: number[]) =>
sendJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }), sendJson<Array<Skill | GameVersion | NamedEntity>>(`/api/admin/config/${type}/order`, 'PUT', { ids }),
updateConfig: ( updateConfig: (
type: ConfigType, type: ConfigType,
id: number, id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string } payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; changeLog?: string }
) => ) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload), sendJson<Skill | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | boolean | undefined>) => pokemon: (params: Record<string, string | number | boolean | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`), getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),

View File

@@ -53,7 +53,6 @@ import {
type Habitat, type Habitat,
type Item, type Item,
type Language, type Language,
type LifeCategory,
type NamedEntity, type NamedEntity,
type Permission, type Permission,
type PermissionPayload, type PermissionPayload,
@@ -93,11 +92,9 @@ type AdminTab =
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & { type EditableConfig = (NamedEntity | Skill | GameVersion) & {
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean; hasTrading?: boolean;
isDefault?: boolean;
isRateable?: boolean;
changeLog?: string; changeLog?: string;
}; };
type RateLimitPolicyForm = { type RateLimitPolicyForm = {
@@ -207,8 +204,6 @@ const configTypes = computed<
label: string; label: string;
supportsItemDrop?: boolean; supportsItemDrop?: boolean;
supportsTrading?: boolean; supportsTrading?: boolean;
supportsDefault?: boolean;
supportsRateable?: boolean;
supportsChangeLog?: boolean; supportsChangeLog?: boolean;
}> }>
>(() => [ >(() => [
@@ -218,7 +213,6 @@ const configTypes = computed<
{ key: 'favorite-things', label: t('config.favoriteThings') }, { key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') }, { key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }, { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
{ key: 'dish-flavors', label: t('config.dishFlavors') } { key: 'dish-flavors', label: t('config.dishFlavors') }
]); ]);
@@ -255,8 +249,6 @@ const configForm = ref({
translations: {} as TranslationMap, translations: {} as TranslationMap,
hasItemDrop: false, hasItemDrop: false,
hasTrading: false, hasTrading: false,
isDefault: false,
isRateable: false,
changeLog: '' changeLog: ''
}); });
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap }); const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
@@ -618,7 +610,7 @@ async function loadLanguages() {
} }
function resetConfigForm() { function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' }; configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' };
} }
function resetChecklistForm() { function resetChecklistForm() {
@@ -729,8 +721,6 @@ function editConfig(item: EditableConfig) {
translations: item.translations ?? {}, translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true, hasItemDrop: item.hasItemDrop === true,
hasTrading: item.hasTrading === true, hasTrading: item.hasTrading === true,
isDefault: item.isDefault === true,
isRateable: item.isRateable === true,
changeLog: item.changeLog ?? '' changeLog: item.changeLog ?? ''
}; };
configModalOpen.value = true; configModalOpen.value = true;
@@ -1115,8 +1105,6 @@ async function saveConfig() {
translations: configForm.value.translations, translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined, hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : 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 changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
}; };
@@ -2172,8 +2160,6 @@ onMounted(() => {
{{ item.name }} {{ item.name }}
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span> <span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span> <span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</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>
<span class="row-actions"> <span class="row-actions">
<button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)"> <button v-if="can('admin.config.update')" type="button" :disabled="busy" @click="editConfig(item)">
@@ -3015,18 +3001,6 @@ onMounted(() => {
{{ t('pages.admin.hasTrading') }} {{ t('pages.admin.hasTrading') }}
</label> </label>
</div> </div>
<div v-if="selectedConfig.supportsDefault" class="check-row">
<label>
<input v-model="configForm.isDefault" type="checkbox" />
{{ 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"> <div v-if="selectedConfig.supportsChangeLog" class="field">
<label for="config-change-log">{{ t('pages.admin.changeLog') }}</label> <label for="config-change-log">{{ t('pages.admin.changeLog') }}</label>
<textarea id="config-change-log" v-model="configForm.changeLog"></textarea> <textarea id="config-change-log" v-model="configForm.changeLog"></textarea>

View File

@@ -4,7 +4,6 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import ConfirmDialog from '../components/ConfirmDialog.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue'; import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
@@ -64,8 +63,6 @@ const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null); const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null); const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({}); 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 moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null); const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
@@ -89,7 +86,6 @@ function can(permissionKey: string) {
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like')); const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set'));
const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved'); const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved');
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [ const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'oldest', label: t('pages.life.sortOldest') },
@@ -279,10 +275,6 @@ function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId; return reactionBusyPostId.value === postId;
} }
function isRatingBusy(postId: number) {
return ratingBusyPostId.value === postId;
}
function canManage(currentPost: LifePost) { function canManage(currentPost: LifePost) {
return (currentUser.value?.id === currentPost.author?.id && can('life.posts.update')) || can('life.posts.update-any'); return (currentUser.value?.id === currentPost.author?.id && can('life.posts.update')) || can('life.posts.update-any');
} }
@@ -316,10 +308,6 @@ function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null; return canReact.value && reactionBusyPostId.value === null;
} }
function canUseRatings(currentPost: LifePost) {
return canRate.value && ratingBusyPostId.value === null && currentPost.moderationStatus === 'approved' && currentPost.category?.isRateable === true;
}
function reactionTotal(currentPost: LifePost) { function reactionTotal(currentPost: LifePost) {
return reactionOptions.reduce((count, option) => count + (currentPost.reactionCounts[option.type] ?? 0), 0); return reactionOptions.reduce((count, option) => count + (currentPost.reactionCounts[option.type] ?? 0), 0);
} }
@@ -546,25 +534,6 @@ async function toggleReaction(currentPost: LifePost, reactionType: LifeReactionT
} }
} }
async function toggleRating(currentPost: LifePost, rating: number) {
if (!canUseRatings(currentPost)) {
return;
}
ratingBusyPostId.value = currentPost.id;
clearRatingError(currentPost.id);
try {
const updatedPost =
currentPost.myRating === rating ? await api.deleteLifeRating(currentPost.id) : await api.setLifeRating(currentPost.id, rating);
replacePost(updatedPost);
} catch (error) {
setRatingError(currentPost.id, error instanceof Error && error.message ? error.message : t('pages.life.ratingFailed'));
} finally {
ratingBusyPostId.value = null;
}
}
function setReactionError(postId: number, message: string) { function setReactionError(postId: number, message: string) {
reactionErrors.value = { ...reactionErrors.value, [postId]: message }; reactionErrors.value = { ...reactionErrors.value, [postId]: message };
} }
@@ -575,16 +544,6 @@ function clearReactionError(postId: number) {
reactionErrors.value = nextErrors; 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 setCommentError(key: string, message: string) { function setCommentError(key: string, message: string) {
commentErrors.value = { ...commentErrors.value, [key]: message }; commentErrors.value = { ...commentErrors.value, [key]: message };
} }
@@ -929,8 +888,7 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p> <p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')"> <div v-if="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"> <span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
{{ post.gameVersion.name }} {{ post.gameVersion.name }}
@@ -944,16 +902,6 @@ onUnmounted(() => {
<div class="life-post__engagement"> <div class="life-post__engagement">
<div class="life-post__engagement-actions"> <div class="life-post__engagement-actions">
<LifeRatingControl
v-if="post.category?.isRateable"
:rating-average="post.ratingAverage"
:rating-count="post.ratingCount"
:my-rating="post.myRating"
:disabled="!canUseRatings(post)"
:busy="isRatingBusy(post.id)"
@rate="toggleRating(post, $event)"
/>
<div class="life-reactions"> <div class="life-reactions">
<div class="life-reaction-control"> <div class="life-reaction-control">
<button <button
@@ -1068,7 +1016,6 @@ onUnmounted(() => {
<span>{{ post.moderationReason }}</span> <span>{{ post.moderationReason }}</span>
</p> </p>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p> <p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p> <p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>

View File

@@ -4,7 +4,6 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import ConfirmDialog from '../components/ConfirmDialog.vue'; import ConfirmDialog from '../components/ConfirmDialog.vue';
import FilterPanel from '../components/FilterPanel.vue'; import FilterPanel from '../components/FilterPanel.vue';
import LifeRatingControl from '../components/LifeRatingControl.vue';
import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue'; import LifeReactionUsersModal from '../components/LifeReactionUsersModal.vue';
import LoadMoreSentinel from '../components/LoadMoreSentinel.vue'; import LoadMoreSentinel from '../components/LoadMoreSentinel.vue';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
@@ -43,7 +42,6 @@ import {
type CommentSort, type CommentSort,
type GameVersion, type GameVersion,
type Language, type Language,
type LifeCategory,
type LifeComment, type LifeComment,
type LifePost, type LifePost,
type LifePostsPage, type LifePostsPage,
@@ -62,13 +60,12 @@ type LifeCommentPageState = {
error: string; error: string;
}; };
type LifePostSort = 'latest' | 'oldest' | 'top-rated'; type LifePostSort = 'latest' | 'oldest';
type LifeFeedScope = 'all' | 'following'; type LifeFeedScope = 'all' | 'following';
type PendingLifeDelete = { type: 'post'; post: LifePost } | { type: 'comment'; post: LifePost; comment: LifeComment }; type PendingLifeDelete = { type: 'post'; post: LifePost } | { type: 'comment'; post: LifePost; comment: LifeComment };
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const posts = ref<LifePost[]>([]); const posts = ref<LifePost[]>([]);
const lifeCategories = ref<LifeCategory[]>([]);
const gameVersions = ref<GameVersion[]>([]); const gameVersions = ref<GameVersion[]>([]);
const languages = ref<Language[]>([]); const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
@@ -78,14 +75,11 @@ const authReady = ref(false);
const busy = ref(false); const busy = ref(false);
const searchDraft = ref(''); const searchDraft = ref('');
const submittedSearch = ref(''); const submittedSearch = ref('');
const activeCategoryId = ref('all');
const activeLanguageCode = ref('all'); const activeLanguageCode = ref('all');
const activeGameVersionId = ref('all'); const activeGameVersionId = ref('all');
const activeRateableFilter = ref('all');
const activeSort = ref<LifePostSort>('latest'); const activeSort = ref<LifePostSort>('latest');
const activeFeedScope = ref<LifeFeedScope>('all'); const activeFeedScope = ref<LifeFeedScope>('all');
const body = ref(''); const body = ref('');
const selectedCategoryId = ref('');
const selectedGameVersionId = ref(''); const selectedGameVersionId = ref('');
const editingPostId = ref<number | null>(null); const editingPostId = ref<number | null>(null);
const postModalOpen = ref(false); const postModalOpen = ref(false);
@@ -102,8 +96,6 @@ const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null); const reactionPickerPostId = ref<number | null>(null);
const reactionBusyPostId = ref<number | null>(null); const reactionBusyPostId = ref<number | null>(null);
const reactionErrors = ref<Record<number, string>>({}); 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 moderationBusyPostId = ref<number | null>(null);
const moderationErrors = ref<Record<number, string>>({}); const moderationErrors = ref<Record<number, string>>({});
const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null); const reactionUsersModal = ref<{ postId: number; reactionType: LifeReactionType | null } | null>(null);
@@ -123,7 +115,6 @@ let postsRequestId = 0;
const nextCursor = ref<string | null>(null); const nextCursor = ref<string | null>(null);
const hasMorePosts = ref(false); const hasMorePosts = ref(false);
const loadMorePaused = ref(false); const loadMorePaused = ref(false);
const allCategoryValue = 'all';
const allLanguageValue = 'all'; const allLanguageValue = 'all';
const allGameVersionValue = 'all'; const allGameVersionValue = 'all';
const deleteConfirmTitle = computed(() => const deleteConfirmTitle = computed(() =>
@@ -134,7 +125,7 @@ const deleteConfirmMessage = computed(() =>
); );
type LifeInitialData = { type LifeInitialData = {
options: { lifeCategories: LifeCategory[]; gameVersions: GameVersion[] } | null; options: { gameVersions: GameVersion[] } | null;
languages: Language[] | null; languages: Language[] | null;
posts: LifePostsPage | null; posts: LifePostsPage | null;
}; };
@@ -154,7 +145,7 @@ const { data: initialData } = await useAsyncData<LifeInitialData>(
return { return {
options: options:
optionsResult.status === 'fulfilled' optionsResult.status === 'fulfilled'
? { lifeCategories: optionsResult.value.lifeCategories, gameVersions: optionsResult.value.gameVersions } ? { gameVersions: optionsResult.value.gameVersions }
: null, : null,
languages: languagesResult.status === 'fulfilled' ? languagesResult.value.filter((language) => language.enabled) : null, languages: languagesResult.status === 'fulfilled' ? languagesResult.value.filter((language) => language.enabled) : null,
posts: postsResult.status === 'fulfilled' ? postsResult.value : null posts: postsResult.status === 'fulfilled' ? postsResult.value : null
@@ -163,7 +154,6 @@ const { data: initialData } = await useAsyncData<LifeInitialData>(
{ default: () => ({ options: null, languages: null, posts: null }) } { default: () => ({ options: null, languages: null, posts: null }) }
); );
lifeCategories.value = initialData.value.options?.lifeCategories ?? [];
gameVersions.value = initialData.value.options?.gameVersions ?? []; gameVersions.value = initialData.value.options?.gameVersions ?? [];
languages.value = initialData.value.languages ?? []; languages.value = initialData.value.languages ?? [];
posts.value = initialData.value.posts?.items ?? []; posts.value = initialData.value.posts?.items ?? [];
@@ -189,14 +179,9 @@ const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like')); const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); 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 charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
const isEditing = computed(() => editingPostId.value !== null); const isEditing = computed(() => editingPostId.value !== null);
const searchQuery = computed(() => submittedSearch.value.trim()); const searchQuery = computed(() => submittedSearch.value.trim());
const selectedFeedCategoryId = computed(() => {
const categoryId = Number(activeCategoryId.value);
return activeCategoryId.value === allCategoryValue || !Number.isInteger(categoryId) || categoryId <= 0 ? undefined : categoryId;
});
const selectedFeedLanguageCode = computed(() => const selectedFeedLanguageCode = computed(() =>
activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value
); );
@@ -206,19 +191,6 @@ const selectedFeedGameVersionId = computed(() => {
? undefined ? undefined
: gameVersionId; : 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 }))
]);
const languageFilterOptions = computed<TabOption[]>(() => [ const languageFilterOptions = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('pages.life.allLanguages') }, { value: allLanguageValue, label: t('pages.life.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name })) ...languages.value.map((language) => ({ value: language.code, label: language.name }))
@@ -227,15 +199,9 @@ const gameVersionFilterOptions = computed(() => [
{ value: allGameVersionValue, label: t('pages.life.allVersions') }, { value: allGameVersionValue, label: t('pages.life.allVersions') },
...gameVersions.value.map((version) => ({ value: String(version.id), label: version.name })) ...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 }>>(() => [ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() => [
{ value: 'latest', label: t('pages.life.sortLatest') }, { value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'oldest', label: t('pages.life.sortOldest') }
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
]); ]);
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [ const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') }, { value: 'oldest', label: t('pages.life.sortOldest') },
@@ -247,10 +213,6 @@ const feedScopeOptions = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allFeed') }, { value: 'all', label: t('pages.life.allFeed') },
{ value: 'following', label: t('pages.life.followingFeed') } { value: 'following', label: t('pages.life.followingFeed') }
]); ]);
const defaultLifeCategoryId = computed(() => {
const category = lifeCategories.value.find((item) => item.isDefault);
return category ? String(category.id) : '';
});
const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost'))); const postModalTitle = computed(() => (isEditing.value ? t('pages.life.editPost') : t('pages.life.newPost')));
const submitLabel = computed(() => { const submitLabel = computed(() => {
if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing'); if (busy.value) return isEditing.value ? t('pages.life.updating') : t('pages.life.publishing');
@@ -271,27 +233,17 @@ async function loadCurrentUser() {
} }
} }
async function loadLifeCategories() { async function loadLifeOptions() {
try { try {
const options = await api.options(); const options = await api.options();
lifeCategories.value = options.lifeCategories;
gameVersions.value = options.gameVersions; gameVersions.value = options.gameVersions;
if (activeCategoryId.value !== allCategoryValue && !lifeCategories.value.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = allCategoryValue;
}
if ( if (
activeGameVersionId.value !== allGameVersionValue && activeGameVersionId.value !== allGameVersionValue &&
!gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value) !gameVersions.value.some((gameVersion) => String(gameVersion.id) === activeGameVersionId.value)
) { ) {
activeGameVersionId.value = allGameVersionValue; 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)) { if (selectedGameVersionId.value && !gameVersions.value.some((gameVersion) => String(gameVersion.id) === selectedGameVersionId.value)) {
selectedGameVersionId.value = ''; selectedGameVersionId.value = '';
} }
@@ -328,10 +280,8 @@ async function loadPosts() {
const params = { const params = {
limit: lifePostPageSize, limit: lifePostPageSize,
search: searchQuery.value, search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value, language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value, gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value sort: activeSort.value
}; };
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params); const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
@@ -374,10 +324,8 @@ async function loadMorePosts() {
cursor, cursor,
limit: lifePostPageSize, limit: lifePostPageSize,
search: searchQuery.value, search: searchQuery.value,
categoryId: selectedFeedCategoryId.value,
language: selectedFeedLanguageCode.value, language: selectedFeedLanguageCode.value,
gameVersionId: selectedFeedGameVersionId.value, gameVersionId: selectedFeedGameVersionId.value,
rateable: selectedRateableFilter.value,
sort: activeSort.value sort: activeSort.value
}; };
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params); const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
@@ -403,7 +351,6 @@ async function loadMorePosts() {
function resetForm() { function resetForm() {
body.value = ''; body.value = '';
selectedCategoryId.value = '';
selectedGameVersionId.value = ''; selectedGameVersionId.value = '';
editingPostId.value = null; editingPostId.value = null;
formError.value = ''; formError.value = '';
@@ -412,17 +359,11 @@ function resetForm() {
function payload() { function payload() {
return { return {
body: body.value.trim(), body: body.value.trim(),
categoryId: selectedLifeCategoryId() ?? 0,
gameVersionId: selectedGameVersionForPost(), gameVersionId: selectedGameVersionForPost(),
languageCode: selectedFeedLanguageCode.value ?? null languageCode: selectedFeedLanguageCode.value ?? null
}; };
} }
function selectedLifeCategoryId() {
const categoryId = Number(selectedCategoryId.value);
return Number.isInteger(categoryId) && categoryId > 0 ? categoryId : null;
}
function selectedGameVersionForPost() { function selectedGameVersionForPost() {
const gameVersionId = Number(selectedGameVersionId.value); const gameVersionId = Number(selectedGameVersionId.value);
return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null; return Number.isInteger(gameVersionId) && gameVersionId > 0 ? gameVersionId : null;
@@ -459,21 +400,16 @@ function retryLoadMore() {
function matchesCurrentFilters(post: LifePost) { function matchesCurrentFilters(post: LifePost) {
const keyword = searchQuery.value.toLowerCase(); const keyword = searchQuery.value.toLowerCase();
const categoryId = selectedFeedCategoryId.value;
const gameVersionId = selectedFeedGameVersionId.value; const gameVersionId = selectedFeedGameVersionId.value;
const rateable = selectedRateableFilter.value;
const matchesSearch = keyword === '' || post.body.toLowerCase().includes(keyword); 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 matchesGameVersion = gameVersionId === undefined || post.gameVersion?.id === gameVersionId;
const matchesRateable = rateable === null || post.category?.isRateable === rateable;
const matchesLanguage = const matchesLanguage =
selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value; selectedFeedLanguageCode.value === undefined || post.moderationLanguageCode === selectedFeedLanguageCode.value;
return matchesSearch && matchesCategory && matchesGameVersion && matchesRateable && matchesLanguage; return matchesSearch && matchesGameVersion && matchesLanguage;
} }
function openCreatePostModal() { function openCreatePostModal() {
resetForm(); resetForm();
selectedCategoryId.value = defaultLifeCategoryId.value;
postModalOpen.value = true; postModalOpen.value = true;
void nextTick(() => bodyInput.value?.focus()); void nextTick(() => bodyInput.value?.focus());
} }
@@ -494,12 +430,6 @@ async function submitPost() {
return; return;
} }
if (selectedLifeCategoryId() === null) {
formError.value = t('pages.life.categoryRequired');
document.getElementById('life-post-category')?.focus();
return;
}
busy.value = true; busy.value = true;
formError.value = ''; formError.value = '';
@@ -886,10 +816,6 @@ function isReactionBusy(postId: number) {
return reactionBusyPostId.value === postId; return reactionBusyPostId.value === postId;
} }
function isRatingBusy(postId: number) {
return ratingBusyPostId.value === postId;
}
function commentAuthorName(comment: LifeComment) { function commentAuthorName(comment: LifeComment) {
return comment.author?.displayName ?? t('pages.life.byUnknown'); return comment.author?.displayName ?? t('pages.life.byUnknown');
} }
@@ -923,16 +849,6 @@ function clearReactionError(postId: number) {
reactionErrors.value = nextErrors; 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() { function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null; return canReact.value && reactionBusyPostId.value === null;
} }
@@ -954,10 +870,6 @@ function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment'); return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
} }
function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
}
function closeReactionPicker() { function closeReactionPicker() {
reactionPickerPostId.value = null; reactionPickerPostId.value = null;
} }
@@ -1027,31 +939,9 @@ 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) { function startEdit(post: LifePost) {
editingPostId.value = post.id; editingPostId.value = post.id;
body.value = post.body; body.value = post.body;
selectedCategoryId.value = post.category ? String(post.category.id) : '';
selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : ''; selectedGameVersionId.value = post.gameVersion ? String(post.gameVersion.id) : '';
formError.value = ''; formError.value = '';
postModalOpen.value = true; postModalOpen.value = true;
@@ -1373,9 +1263,6 @@ function observeLoadMore() {
} }
watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' }); watch([loadMoreSentinel, hasMorePosts, loading, loadingMore, loadMorePaused], observeLoadMore, { flush: 'post' });
watch(activeCategoryId, () => {
void loadPosts();
});
watch(activeLanguageCode, () => { watch(activeLanguageCode, () => {
expandedComments.value = {}; expandedComments.value = {};
commentPages.value = {}; commentPages.value = {};
@@ -1384,9 +1271,6 @@ watch(activeLanguageCode, () => {
watch(activeGameVersionId, () => { watch(activeGameVersionId, () => {
void loadPosts(); void loadPosts();
}); });
watch(activeRateableFilter, () => {
void loadPosts();
});
watch(activeSort, () => { watch(activeSort, () => {
void loadPosts(); void loadPosts();
}); });
@@ -1395,7 +1279,7 @@ watch(activeFeedScope, () => {
}); });
watch(locale, () => { watch(locale, () => {
void loadLanguages(); void loadLanguages();
void loadLifeCategories(); void loadLifeOptions();
void loadPosts(); void loadPosts();
}); });
@@ -1410,7 +1294,7 @@ onMounted(() => {
initialLanguagesLoaded.value = true; initialLanguagesLoaded.value = true;
} }
if (!initialOptionsLoaded.value) { if (!initialOptionsLoaded.value) {
await loadLifeCategories(); await loadLifeOptions();
initialOptionsLoaded.value = true; initialOptionsLoaded.value = true;
} }
if (!initialPostsLoaded.value || currentUser.value) { if (!initialPostsLoaded.value || currentUser.value) {
@@ -1473,14 +1357,6 @@ onUnmounted(() => {
</option> </option>
</select> </select>
</div> </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"> <div class="field life-toolbar__select">
<label for="life-sort">{{ t('pages.life.sort') }}</label> <label for="life-sort">{{ t('pages.life.sort') }}</label>
<select id="life-sort" v-model="activeSort"> <select id="life-sort" v-model="activeSort">
@@ -1528,19 +1404,6 @@ onUnmounted(() => {
<span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span> <span class="life-form__counter">{{ t('pages.life.charactersLeft', { count: charactersLeft }) }}</span>
</div> </div>
<div class="field">
<label for="life-post-category">{{ t('pages.life.category') }}</label>
<TagsSelect
id="life-post-category"
v-model="selectedCategoryId"
:options="lifeCategories"
:multiple="false"
:placeholder="t('pages.life.categoryPlaceholder')"
:search-placeholder="t('pages.life.searchCategories')"
dropdown-strategy="fixed"
/>
</div>
<div class="field"> <div class="field">
<label for="life-post-version">{{ t('pages.life.gameVersion') }}</label> <label for="life-post-version">{{ t('pages.life.gameVersion') }}</label>
<TagsSelect <TagsSelect
@@ -1591,7 +1454,6 @@ onUnmounted(() => {
:label="t('pages.life.feedScope')" :label="t('pages.life.feedScope')"
/> />
<Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" /> <Tabs id="life-language-filter" v-model="activeLanguageCode" :tabs="languageFilterOptions" :label="t('pages.life.languages')" />
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
<section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')"> <section class="life-feed" :aria-busy="loading || loadingMore" :aria-label="t('pages.life.kicker')">
<div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')"> <div v-if="loading" class="life-feed__list" :aria-label="t('pages.life.loading')">
@@ -1648,8 +1510,7 @@ onUnmounted(() => {
<p class="life-post__body">{{ post.body }}</p> <p class="life-post__body">{{ post.body }}</p>
<div v-if="post.category || post.gameVersion" class="life-post__tags" :aria-label="t('pages.life.postMeta')"> <div v-if="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"> <span v-if="post.gameVersion" class="life-post__tag life-post__tag--version">
<Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconVersion" class="ui-icon" aria-hidden="true" />
{{ post.gameVersion.name }} {{ post.gameVersion.name }}
@@ -1662,16 +1523,6 @@ onUnmounted(() => {
<div class="life-post__engagement"> <div class="life-post__engagement">
<div class="life-post__engagement-actions"> <div class="life-post__engagement-actions">
<LifeRatingControl
v-if="post.category?.isRateable"
:rating-average="post.ratingAverage"
:rating-count="post.ratingCount"
:my-rating="post.myRating"
:disabled="!canUseRatings(post)"
:busy="isRatingBusy(post.id)"
@rate="toggleRating(post, $event)"
/>
<div class="life-reactions"> <div class="life-reactions">
<div class="life-reaction-control"> <div class="life-reaction-control">
<button <button
@@ -1810,7 +1661,6 @@ onUnmounted(() => {
<span>{{ post.moderationReason }}</span> <span>{{ post.moderationReason }}</span>
</p> </p>
<p v-if="ratingErrors[post.id]" class="life-form__error" role="alert">{{ ratingErrors[post.id] }}</p>
<p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p> <p v-if="moderationErrors[post.id]" class="life-form__error" role="alert">{{ moderationErrors[post.id] }}</p>
<p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p> <p v-if="reactionErrors[post.id]" class="life-form__error" role="alert">{{ reactionErrors[post.id] }}</p>

View File

@@ -683,8 +683,7 @@ function contentTypeLabel(contentType: string): string {
environments: t('config.environments'), environments: t('config.environments'),
'favorite-things': t('config.favoriteThings'), 'favorite-things': t('config.favoriteThings'),
'acquisition-methods': t('config.acquisitionMethods'), 'acquisition-methods': t('config.acquisitionMethods'),
maps: t('config.maps'), maps: t('config.maps')
'life-tags': t('config.lifeCategories')
}; };
return labels[contentType] ?? t('pages.profile.otherContributions'); return labels[contentType] ?? t('pages.profile.otherContributions');
} }
@@ -840,10 +839,6 @@ onMounted(() => {
<p class="life-post__body">{{ post.body }}</p> <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>
<div class="profile-feed-card__metrics"> <div class="profile-feed-card__metrics">
<button <button
class="profile-reaction-open-button" class="profile-reaction-open-button"

View File

@@ -909,30 +909,21 @@ export const systemWordingMessages = {
bodyLabel: 'Post', bodyLabel: 'Post',
bodyPlaceholder: 'Share a thought, tip, or discovery...', bodyPlaceholder: 'Share a thought, tip, or discovery...',
newPost: 'New Post', newPost: 'New Post',
category: 'Category',
gameVersion: 'Game version', gameVersion: 'Game version',
versionPlaceholder: 'No version', versionPlaceholder: 'No version',
searchVersions: 'Search versions', searchVersions: 'Search versions',
languages: 'Languages', languages: 'Languages',
allLanguages: 'All languages', allLanguages: 'All languages',
allCategories: 'All',
feedScope: 'Feed scope', feedScope: 'Feed scope',
allFeed: 'All feed', allFeed: 'All feed',
followingFeed: 'Following', followingFeed: 'Following',
allVersions: 'All versions', allVersions: 'All versions',
versionFilter: 'Version', versionFilter: 'Version',
ratingFilter: 'Rating',
allRatingModes: 'All posts',
rateableOnly: 'Rateable only',
notRateableOnly: 'Not rateable',
sort: 'Sort', sort: 'Sort',
sortLatest: 'Latest', sortLatest: 'Latest',
sortOldest: 'Oldest', sortOldest: 'Oldest',
sortTopRated: 'Top rated',
sortMostLiked: 'Most liked', sortMostLiked: 'Most liked',
sortMostReplied: 'Most replied', sortMostReplied: 'Most replied',
categoryPlaceholder: 'Select category',
searchCategories: 'Search categories',
search: 'Search Life', search: 'Search Life',
searchPlaceholder: 'Search post content...', searchPlaceholder: 'Search post content...',
clearSearch: 'Clear search', clearSearch: 'Clear search',
@@ -967,7 +958,6 @@ export const systemWordingMessages = {
removeRating: 'Remove rating', removeRating: 'Remove rating',
ratingAverage: '{average} average from {count} ratings', ratingAverage: '{average} average from {count} ratings',
noRatings: 'No ratings yet', noRatings: 'No ratings yet',
ratingFailed: 'Rating failed',
commentPlaceholder: 'Write a comment...', commentPlaceholder: 'Write a comment...',
commentReplyPlaceholder: 'Write a reply...', commentReplyPlaceholder: 'Write a reply...',
postComment: 'Post comment', postComment: 'Post comment',
@@ -1010,7 +1000,6 @@ export const systemWordingMessages = {
saveFailed: 'Save failed', saveFailed: 'Save failed',
deleteFailed: 'Delete failed', deleteFailed: 'Delete failed',
bodyRequired: 'Please enter a post.', bodyRequired: 'Please enter a post.',
categoryRequired: 'Please select a category.',
byUnknown: 'Community member', byUnknown: 'Community member',
edited: 'Edited', edited: 'Edited',
deleteConfirm: 'Delete this post?', deleteConfirm: 'Delete this post?',
@@ -1140,7 +1129,6 @@ export const systemWordingMessages = {
editConfig: 'Edit {name}', editConfig: 'Edit {name}',
hasItemDrop: 'Has item drop', hasItemDrop: 'Has item drop',
hasTrading: 'Has trading', hasTrading: 'Has trading',
rateableCategory: 'Rateable',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: 'Drag to reorder: {name}', dragSort: 'Drag to reorder: {name}',
dragSortTitle: 'Drag to reorder', dragSortTitle: 'Drag to reorder',
@@ -1148,7 +1136,6 @@ export const systemWordingMessages = {
languageName: 'Language name', languageName: 'Language name',
enabled: 'Enabled', enabled: 'Enabled',
defaultLanguage: 'Default language', defaultLanguage: 'Default language',
defaultCategory: 'Default category',
sortOrder: 'Sort order', sortOrder: 'Sort order',
newLanguage: 'New language', newLanguage: 'New language',
editLanguage: 'Edit language', editLanguage: 'Edit language',
@@ -1223,7 +1210,6 @@ export const systemWordingMessages = {
favoriteThings: 'Favourites / tags', favoriteThings: 'Favourites / tags',
acquisitionMethods: 'Acquisition methods', acquisitionMethods: 'Acquisition methods',
maps: 'Maps', maps: 'Maps',
lifeCategories: 'Life categories',
gameVersions: 'Game versions', gameVersions: 'Game versions',
dishFlavors: 'Dish flavors' dishFlavors: 'Dish flavors'
}, },
@@ -1386,13 +1372,10 @@ export const systemWordingMessages = {
taskDoesNotExist: 'Task does not exist', taskDoesNotExist: 'Task does not exist',
postRequired: 'Please enter a post', postRequired: 'Please enter a post',
postTooLong: 'Post is too long', postTooLong: 'Post is too long',
lifeCategoryRequired: 'Please select a category',
lifeCategoryInvalid: 'Category is invalid',
gameVersionInvalid: 'Game version is invalid', gameVersionInvalid: 'Game version is invalid',
commentRequired: 'Please enter a comment', commentRequired: 'Please enter a comment',
commentTooLong: 'Comment is too long', commentTooLong: 'Comment is too long',
reactionInvalid: 'Reaction is invalid', reactionInvalid: 'Reaction is invalid',
ratingInvalid: 'Rating is invalid',
cursorInvalid: 'Cursor is invalid', cursorInvalid: 'Cursor is invalid',
tagInvalid: 'Tag is invalid', tagInvalid: 'Tag is invalid',
entityTypeInvalid: 'Entity type is invalid', entityTypeInvalid: 'Entity type is invalid',
@@ -2340,30 +2323,21 @@ export const systemWordingMessages = {
bodyLabel: '动态内容', bodyLabel: '动态内容',
bodyPlaceholder: '分享一段想法、心得或发现……', bodyPlaceholder: '分享一段想法、心得或发现……',
newPost: 'New Post', newPost: 'New Post',
category: 'Category',
gameVersion: '游戏版本', gameVersion: '游戏版本',
versionPlaceholder: '不选择版本', versionPlaceholder: '不选择版本',
searchVersions: '搜索版本', searchVersions: '搜索版本',
languages: '语言区', languages: '语言区',
allLanguages: '全部语言', allLanguages: '全部语言',
allCategories: '全部',
feedScope: '动态范围', feedScope: '动态范围',
allFeed: '全部动态', allFeed: '全部动态',
followingFeed: '关注动态', followingFeed: '关注动态',
allVersions: '全部版本', allVersions: '全部版本',
versionFilter: '版本', versionFilter: '版本',
ratingFilter: '评分',
allRatingModes: '全部动态',
rateableOnly: '仅可评分',
notRateableOnly: '不可评分',
sort: '排序', sort: '排序',
sortLatest: '最新', sortLatest: '最新',
sortOldest: '最早', sortOldest: '最早',
sortTopRated: '评分最高',
sortMostLiked: '点赞最多', sortMostLiked: '点赞最多',
sortMostReplied: '回复最多', sortMostReplied: '回复最多',
categoryPlaceholder: '选择 Category',
searchCategories: '搜索 Category',
search: '搜索动态', search: '搜索动态',
searchPlaceholder: '搜索动态内容……', searchPlaceholder: '搜索动态内容……',
clearSearch: '清除搜索', clearSearch: '清除搜索',
@@ -2398,7 +2372,6 @@ export const systemWordingMessages = {
removeRating: '取消评分', removeRating: '取消评分',
ratingAverage: '{average} 平均分,{count} 人评分', ratingAverage: '{average} 平均分,{count} 人评分',
noRatings: '暂无评分', noRatings: '暂无评分',
ratingFailed: '评分失败',
commentPlaceholder: '写下评论……', commentPlaceholder: '写下评论……',
commentReplyPlaceholder: '写下回复……', commentReplyPlaceholder: '写下回复……',
postComment: '发表评论', postComment: '发表评论',
@@ -2441,7 +2414,6 @@ export const systemWordingMessages = {
saveFailed: '保存失败', saveFailed: '保存失败',
deleteFailed: '删除失败', deleteFailed: '删除失败',
bodyRequired: '请输入动态内容。', bodyRequired: '请输入动态内容。',
categoryRequired: '请选择 Category。',
byUnknown: '社区成员', byUnknown: '社区成员',
edited: '已编辑', edited: '已编辑',
deleteConfirm: '确认删除这条动态?', deleteConfirm: '确认删除这条动态?',
@@ -2571,7 +2543,6 @@ export const systemWordingMessages = {
editConfig: '编辑{name}', editConfig: '编辑{name}',
hasItemDrop: '有掉落物', hasItemDrop: '有掉落物',
hasTrading: '有 Trading', hasTrading: '有 Trading',
rateableCategory: '可评分',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: '拖曳排序:{name}', dragSort: '拖曳排序:{name}',
dragSortTitle: '拖曳排序', dragSortTitle: '拖曳排序',
@@ -2579,7 +2550,6 @@ export const systemWordingMessages = {
languageName: '语言名称', languageName: '语言名称',
enabled: '启用', enabled: '启用',
defaultLanguage: '默认语言', defaultLanguage: '默认语言',
defaultCategory: '默认 Category',
sortOrder: '排序', sortOrder: '排序',
newLanguage: '新增语言', newLanguage: '新增语言',
editLanguage: '编辑语言', editLanguage: '编辑语言',
@@ -2654,7 +2624,6 @@ export const systemWordingMessages = {
favoriteThings: '喜欢的东西 / 标签', favoriteThings: '喜欢的东西 / 标签',
acquisitionMethods: '入手方式', acquisitionMethods: '入手方式',
maps: '地图', maps: '地图',
lifeCategories: 'Life Categories',
gameVersions: '游戏版本', gameVersions: '游戏版本',
dishFlavors: '料理味道' dishFlavors: '料理味道'
}, },
@@ -2817,13 +2786,10 @@ export const systemWordingMessages = {
taskDoesNotExist: '任务不存在', taskDoesNotExist: '任务不存在',
postRequired: '请输入动态内容', postRequired: '请输入动态内容',
postTooLong: '动态内容过长', postTooLong: '动态内容过长',
lifeCategoryRequired: '请选择 Category',
lifeCategoryInvalid: 'Category 不合法',
gameVersionInvalid: '游戏版本不合法', gameVersionInvalid: '游戏版本不合法',
commentRequired: '请输入评论内容', commentRequired: '请输入评论内容',
commentTooLong: '评论内容过长', commentTooLong: '评论内容过长',
reactionInvalid: '互动类型不合法', reactionInvalid: '互动类型不合法',
ratingInvalid: '评分不合法',
cursorInvalid: '分页位置不合法', cursorInvalid: '分页位置不合法',
tagInvalid: '标签不合法', tagInvalid: '标签不合法',
entityTypeInvalid: '实体类型不合法', entityTypeInvalid: '实体类型不合法',