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:
32
DESIGN.md
32
DESIGN.md
@@ -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`
|
||||||
- Ratings:Rateable 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 Feed;Following Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post,并继续支持搜索、语言、Game Version 和排序筛选。
|
||||||
- 登录用户可切换 All Feed 和 Following Feed;Following 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 权限控制:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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: '实体类型不合法',
|
||||||
|
|||||||
Reference in New Issue
Block a user