Compare commits
5 Commits
3dd3998a5c
...
504849c14a
| Author | SHA1 | Date | |
|---|---|---|---|
| 504849c14a | |||
| 8cb8190554 | |||
| 016364a8b8 | |||
| b0e2036965 | |||
| 06e0cbb1c1 |
21
DESIGN.md
21
DESIGN.md
@@ -9,6 +9,7 @@
|
|||||||
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
||||||
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
- 桌面端使用侧边栏导航,侧边栏可折叠为图标栏;移动端继续使用抽屉式侧边栏。
|
||||||
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
- 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。
|
||||||
|
- 全局顶部导航栏提供全站搜索。搜索结果按内容类型分组展示,覆盖 Pokemon、Habitats、Items、Ancient Artifacts、Recipes、Daily CheckList、公开可见的 Life Post 和公开用户 Profile;结果跳转到对应公开详情页、页面锚点或 `/profile/:id`。
|
||||||
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
|
|
||||||
- `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
|
- `DESIGN.md` 是产品行为、数据结构和 API 暴露边界的单一事实来源。
|
||||||
- API 只返回业务需要的字段,不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
|
- API 只返回业务需要的字段,不返回密码、token hash、验证 token、内部调试字段或不必要的元数据。
|
||||||
|
- 全局搜索 API 只返回公开浏览所需的最小结果字段:结果类型、ID、展示标题、目标 URL、可选摘要和可选图片;用户搜索结果只使用公开 Profile 所需的 `id`、`displayName` 和目标 URL,不返回邮箱、角色、权限、Referral、编辑审计、审核原因、token/hash、内部字段或调试信息。
|
||||||
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
- 用户界面只展示业务数据和设计内的文案,不展示提示词、计划、调试信息、字段内部名或修改说明。
|
||||||
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
- 可编辑 Wiki 内容必须记录创建者、最后编辑者、创建时间、最后编辑时间和编辑历史。
|
||||||
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
|
- 列表顺序由 `sort_order` 控制,默认按创建时间旧到新初始化,排序值按 10 递增以便后续插入和拖拽排序。
|
||||||
@@ -125,6 +127,10 @@
|
|||||||
- 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。
|
- 登录用户可通过 `/profile` 查看自己的账号资料、邮箱验证状态、Referral 信息和公开主页内容。
|
||||||
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。
|
- 任意用户可通过 `/profile/:id` 访问其他用户的公开 Profile。
|
||||||
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。
|
- 公开 Profile 展示用户公开摘要、Life Feeds、Wiki 贡献统计、Like / Reaction 过的 Life Post 和评论过的内容。
|
||||||
|
- 用户可 Follow 其他用户;Follow 是单向关系,双方互相 Follow 时在展示层视为 Friends。
|
||||||
|
- Friend 不单独存储为独立关系,始终由双向 Follow 派生,避免双写不一致。
|
||||||
|
- 公开 Profile 展示 Followers、Following 和 Friends 数量;登录用户查看其他用户 Profile 时可看到自己与对方的关系状态:未关注、已关注、被对方关注或 Friends。
|
||||||
|
- 登录且邮箱已验证并拥有 `users.follow` 权限的用户可以 Follow / Unfollow 其他用户;用户不能 Follow 自己。
|
||||||
- Profile 的 Feeds 和 Reactions 中可从 Life Post 的 Reaction 汇总或 Reaction 活动打开公开 Reaction 用户列表 Modal。
|
- Profile 的 Feeds 和 Reactions 中可从 Life Post 的 Reaction 汇总或 Reaction 活动打开公开 Reaction 用户列表 Modal。
|
||||||
- Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。
|
- Profile 使用 Tabs 组织:Feeds、Contributions、Reactions、Comments;仅自己的 `/profile` 额外展示 Account。
|
||||||
- Contributions、Reactions、Comments 在对应 Tab 内提供二级分类:Contributions 可按主要内容类型或配置类查看,Reactions 可按 reaction 类型查看,Comments 可按 Life / Wiki discussion 来源查看。
|
- Contributions、Reactions、Comments 在对应 Tab 内提供二级分类:Contributions 可按主要内容类型或配置类查看,Reactions 可按 reaction 类型查看,Comments 可按 Life / Wiki discussion 来源查看。
|
||||||
@@ -254,6 +260,7 @@
|
|||||||
- 通知和审核状态实时更新可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
- 通知和审核状态实时更新可以走 WebSocket;WebSocket 连接使用短期一次性 ticket,不把 session token 放入 WebSocket URL。
|
||||||
- AI 审核从 `reviewing` 变更为 `approved`、`rejected` 或 `failed` 后,前端当前可见的对应 Life Post、Life Comment 或实体讨论评论状态、语言区和可展示的审核原因详情应通过 WebSocket 直接更新,不要求用户刷新页面。
|
- AI 审核从 `reviewing` 变更为 `approved`、`rejected` 或 `failed` 后,前端当前可见的对应 Life Post、Life Comment 或实体讨论评论状态、语言区和可展示的审核原因详情应通过 WebSocket 直接更新,不要求用户刷新页面。
|
||||||
- 通知范围:
|
- 通知范围:
|
||||||
|
- 用户被别人 Follow 时,通知被 Follow 的用户;同一用户重复 Follow 同一目标时合并更新同一通知。
|
||||||
- Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。
|
- Life Post 收到审核通过后的顶层评论时,通知 Life Post 作者。
|
||||||
- Life Comment 收到审核通过后的回复时,通知父评论作者。
|
- Life Comment 收到审核通过后的回复时,通知父评论作者。
|
||||||
- 实体讨论评论收到审核通过后的回复时,通知父评论作者。
|
- 实体讨论评论收到审核通过后的回复时,通知父评论作者。
|
||||||
@@ -275,6 +282,7 @@
|
|||||||
- `updatedAt`
|
- `updatedAt`
|
||||||
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
|
- 通知 API 不返回邮箱、角色、权限、session、token/hash、AI prompt、模型响应、内部审核错误、错误堆栈、调试字段或内部审计 payload。
|
||||||
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
|
- 前端在主导航登录区展示通知入口、未读数量和通知列表;点击通知后标记已读并跳转到对应 Life Post 或 Wiki 详情页。
|
||||||
|
- Follow 对象发布 Life Post 的动态属于 Following Feed,不进入 Notifications,不产生未读数量,也不需要标记已读。
|
||||||
|
|
||||||
## 滥用防护与限流
|
## 滥用防护与限流
|
||||||
|
|
||||||
@@ -815,7 +823,7 @@ 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.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.comments.delete` 权限时可以删除自己的评论;拥有 `life.comments.delete-any` 权限的用户可以删除其他用户评论;删除后的 Life Comment 仅对该评论作者本人可见并保留正文,作者可通过 Undo 恢复;其他用户不可见,不显示 Deleted Comment 占位,不出现在评论列表、评论预览或评论数量中。
|
||||||
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
- 已软删除的 Life Post 不出现在信息流、搜索或 Category 筛选结果中,也不能继续编辑、评论或设置 Reaction。
|
||||||
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
|
||||||
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
|
||||||
@@ -832,14 +840,15 @@ Life Post 可配置:
|
|||||||
- Feed 支持按 Game Version 筛选;All versions 表示不过滤版本。
|
- Feed 支持按 Game Version 筛选;All versions 表示不过滤版本。
|
||||||
- Feed 支持 Rateable 筛选;All 表示不过滤,Rateable only 只展示可评分 Category 下的 Post。
|
- Feed 支持 Rateable 筛选;All 表示不过滤,Rateable only 只展示可评分 Category 下的 Post。
|
||||||
- Feed 支持排序:Latest 默认按创建时间倒序;Oldest 按创建时间正序;Top rated 按平均评分倒序,同分时按创建时间倒序。
|
- Feed 支持排序:Latest 默认按创建时间倒序;Oldest 按创建时间正序;Top rated 按平均评分倒序,同分时按创建时间倒序。
|
||||||
|
- 登录用户可切换 All Feed 和 Following Feed;Following Feed 只展示当前用户已 Follow 用户发布且当前用户可见的 Life Post,并继续支持 Life Category、语言、Game Version、Rateable 和排序筛选。
|
||||||
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
- 信息流分页加载,初始展示最新一页,滚动到底部自动加载更多。
|
||||||
- 当前没有图片上传、转发或置顶。
|
- 当前没有图片上传、转发或置顶。
|
||||||
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
- Life Post 和 Life Comment 必须进入 AI 审核;未审核通过的内容不向普通访客公开。
|
||||||
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
|
- 作者本人和拥有对应 `*-any` 管理权限的用户可以看到相关内容的审核状态,并可触发重新审核。
|
||||||
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
|
- 未审核通过的 Life Post 详情只对作者本人和拥有对应管理权限的用户可见;普通访客访问时返回未找到。
|
||||||
- Life Post 必须展示审核状态:审核中、未审核、审核失败、审核不通过;审核通过也可作为状态展示。
|
- Life Post 必须展示未通过或未完成的审核状态:审核中、未审核、审核失败、审核不通过;审核通过不显示状态标签。
|
||||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||||
- Life Comment 和回复审核通过后才出现在普通公开评论列表、评论数量和评论预览中。
|
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。
|
||||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||||
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
||||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||||
@@ -1001,13 +1010,16 @@ API 暴露边界:
|
|||||||
- `GET /api/recipes`
|
- `GET /api/recipes`
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `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 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
||||||
|
- `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。
|
||||||
- `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` 按审核语言区筛选。
|
- `GET /api/life-posts/:postId/comments`:支持 `cursor` / `limit` 分页读取 Life Post 评论;支持 `language` 按审核语言区筛选。
|
||||||
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计和公开社区统计。
|
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。
|
||||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||||
|
- `PUT /api/users/:id/follow`:需要 `users.follow`;Follow 指定用户并返回更新后的公开 Profile。
|
||||||
|
- `DELETE /api/users/:id/follow`:需要 `users.follow`;Unfollow 指定用户并返回更新后的公开 Profile。
|
||||||
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`、`ancient-artifacts`。
|
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`、`ancient-artifacts`。
|
||||||
|
|
||||||
认证 API:
|
认证 API:
|
||||||
@@ -1061,6 +1073,7 @@ API 暴露边界:
|
|||||||
- `POST /api/life-posts/:postId/comments`
|
- `POST /api/life-posts/:postId/comments`
|
||||||
- `POST /api/life-posts/:postId/comments/:commentId/replies`
|
- `POST /api/life-posts/:postId/comments/:commentId/replies`
|
||||||
- `DELETE /api/life-comments/:id`
|
- `DELETE /api/life-comments/:id`
|
||||||
|
- `POST /api/life-comments/:id/restore`
|
||||||
- `POST /api/life-comments/:id/moderation/retry`
|
- `POST /api/life-comments/:id/moderation/retry`
|
||||||
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
|
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
|
||||||
- `POST /api/discussions/:entityType/:entityId/comments`
|
- `POST /api/discussions/:entityType/:entityId/comments`
|
||||||
|
|||||||
@@ -94,6 +94,17 @@ CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx
|
|||||||
CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx
|
CREATE INDEX IF NOT EXISTS users_referred_by_user_id_idx
|
||||||
ON users(referred_by_user_id);
|
ON users(referred_by_user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_follows (
|
||||||
|
follower_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
followed_user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (follower_user_id, followed_user_id),
|
||||||
|
CHECK (follower_user_id <> followed_user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS user_follows_followed_created_idx
|
||||||
|
ON user_follows(followed_user_id, created_at DESC, follower_user_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS environments (
|
CREATE TABLE IF NOT EXISTS environments (
|
||||||
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,
|
||||||
@@ -290,6 +301,7 @@ 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.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),
|
('life.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
|
||||||
|
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
|
||||||
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
||||||
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
('discussions.comments.delete', 'Delete own discussion comments', 'Delete own entity discussion comments.', 'Discussions', true),
|
||||||
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true)
|
('discussions.comments.delete-any', 'Delete any discussion comment', 'Delete any entity discussion comment.', 'Discussions', true)
|
||||||
@@ -381,6 +393,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.delete-any',
|
'life.comments.delete-any',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'life.ratings.set',
|
||||||
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete',
|
'discussions.comments.delete',
|
||||||
'discussions.comments.delete-any'
|
'discussions.comments.delete-any'
|
||||||
@@ -449,6 +462,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.delete',
|
'life.comments.delete',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'life.ratings.set',
|
||||||
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete'
|
'discussions.comments.delete'
|
||||||
])
|
])
|
||||||
@@ -496,6 +510,7 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'life.comments.delete',
|
'life.comments.delete',
|
||||||
'life.reactions.set',
|
'life.reactions.set',
|
||||||
'life.ratings.set',
|
'life.ratings.set',
|
||||||
|
'users.follow',
|
||||||
'discussions.comments.create',
|
'discussions.comments.create',
|
||||||
'discussions.comments.delete'
|
'discussions.comments.delete'
|
||||||
])
|
])
|
||||||
@@ -514,6 +529,13 @@ JOIN permissions p ON p.key = 'life.ratings.set'
|
|||||||
WHERE r.key IN ('admin', 'editor', 'member')
|
WHERE r.key IN ('admin', 'editor', '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 = 'users.follow'
|
||||||
|
WHERE r.key IN ('admin', 'editor', 'member')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
WITH first_owner_user AS (
|
WITH first_owner_user AS (
|
||||||
SELECT u.id
|
SELECT u.id
|
||||||
FROM users u
|
FROM users u
|
||||||
@@ -1231,10 +1253,12 @@ CREATE TABLE IF NOT EXISTS notifications (
|
|||||||
'life_comment_reply',
|
'life_comment_reply',
|
||||||
'discussion_comment_reply',
|
'discussion_comment_reply',
|
||||||
'life_post_reaction',
|
'life_post_reaction',
|
||||||
|
'user_follow',
|
||||||
'moderation_result'
|
'moderation_result'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
life_post_id integer REFERENCES life_posts(id) ON DELETE CASCADE,
|
life_post_id integer REFERENCES life_posts(id) ON DELETE CASCADE,
|
||||||
|
profile_user_id integer REFERENCES users(id) ON DELETE CASCADE,
|
||||||
life_comment_id integer REFERENCES life_post_comments(id) ON DELETE CASCADE,
|
life_comment_id integer REFERENCES life_post_comments(id) ON DELETE CASCADE,
|
||||||
parent_life_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
|
parent_life_comment_id integer REFERENCES life_post_comments(id) ON DELETE SET NULL,
|
||||||
discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
discussion_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
||||||
@@ -1274,6 +1298,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS notifications_life_post_reaction_unique_idx
|
|||||||
ON notifications(recipient_user_id, actor_user_id, life_post_id)
|
ON notifications(recipient_user_id, actor_user_id, life_post_id)
|
||||||
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
|
WHERE type = 'life_post_reaction' AND actor_user_id IS NOT NULL AND life_post_id IS NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE notifications
|
||||||
|
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS notifications_user_follow_unique_idx
|
||||||
|
ON notifications(recipient_user_id, actor_user_id, profile_user_id)
|
||||||
|
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS notification_ws_tickets (
|
CREATE TABLE IF NOT EXISTS notification_ws_tickets (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -1289,6 +1320,24 @@ CREATE INDEX IF NOT EXISTS notification_ws_tickets_user_idx
|
|||||||
ALTER TABLE notifications
|
ALTER TABLE notifications
|
||||||
ADD COLUMN IF NOT EXISTS moderation_reason text;
|
ADD COLUMN IF NOT EXISTS moderation_reason text;
|
||||||
|
|
||||||
|
ALTER TABLE notifications
|
||||||
|
ADD COLUMN IF NOT EXISTS profile_user_id integer REFERENCES users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE notifications
|
||||||
|
DROP CONSTRAINT IF EXISTS notifications_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE notifications
|
||||||
|
ADD CONSTRAINT notifications_type_check CHECK (
|
||||||
|
type IN (
|
||||||
|
'life_post_comment',
|
||||||
|
'life_comment_reply',
|
||||||
|
'discussion_comment_reply',
|
||||||
|
'life_post_reaction',
|
||||||
|
'user_follow',
|
||||||
|
'moderation_result'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
ALTER TABLE life_tags
|
ALTER TABLE life_tags
|
||||||
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ type NotificationType =
|
|||||||
| 'life_comment_reply'
|
| 'life_comment_reply'
|
||||||
| 'discussion_comment_reply'
|
| 'discussion_comment_reply'
|
||||||
| 'life_post_reaction'
|
| 'life_post_reaction'
|
||||||
|
| 'user_follow'
|
||||||
| 'moderation_result';
|
| 'moderation_result';
|
||||||
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
type LifeReactionType = 'like' | 'helpful' | 'fun' | 'thanks';
|
||||||
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
||||||
type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user';
|
||||||
type ModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
type ModerationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
||||||
|
|
||||||
type NotificationCursor = {
|
type NotificationCursor = {
|
||||||
@@ -35,6 +36,7 @@ type NotificationRow = {
|
|||||||
actor: NotificationActor | null;
|
actor: NotificationActor | null;
|
||||||
type: NotificationType;
|
type: NotificationType;
|
||||||
lifePostId: number | null;
|
lifePostId: number | null;
|
||||||
|
profileUserId: number | null;
|
||||||
lifeCommentId: number | null;
|
lifeCommentId: number | null;
|
||||||
parentLifeCommentId: number | null;
|
parentLifeCommentId: number | null;
|
||||||
discussionCommentId: number | null;
|
discussionCommentId: number | null;
|
||||||
@@ -55,6 +57,7 @@ export type NotificationTarget = {
|
|||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
lifePostId: number | null;
|
lifePostId: number | null;
|
||||||
|
profileUserId: number | null;
|
||||||
lifeCommentId: number | null;
|
lifeCommentId: number | null;
|
||||||
discussionCommentId: number | null;
|
discussionCommentId: number | null;
|
||||||
entityType: DiscussionEntityType | null;
|
entityType: DiscussionEntityType | null;
|
||||||
@@ -147,6 +150,7 @@ function notificationProjection(): string {
|
|||||||
n.recipient_user_id AS "recipientUserId",
|
n.recipient_user_id AS "recipientUserId",
|
||||||
n.type,
|
n.type,
|
||||||
n.life_post_id AS "lifePostId",
|
n.life_post_id AS "lifePostId",
|
||||||
|
n.profile_user_id AS "profileUserId",
|
||||||
n.life_comment_id AS "lifeCommentId",
|
n.life_comment_id AS "lifeCommentId",
|
||||||
n.parent_life_comment_id AS "parentLifeCommentId",
|
n.parent_life_comment_id AS "parentLifeCommentId",
|
||||||
n.discussion_comment_id AS "discussionCommentId",
|
n.discussion_comment_id AS "discussionCommentId",
|
||||||
@@ -178,6 +182,9 @@ function discussionEntityPath(entityType: DiscussionEntityType | null, entityId:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notificationTargetType(row: NotificationRow): NotificationTargetType {
|
function notificationTargetType(row: NotificationRow): NotificationTargetType {
|
||||||
|
if (row.profileUserId !== null) {
|
||||||
|
return 'profile-user';
|
||||||
|
}
|
||||||
if (row.discussionCommentId !== null) {
|
if (row.discussionCommentId !== null) {
|
||||||
return 'discussion-comment';
|
return 'discussion-comment';
|
||||||
}
|
}
|
||||||
@@ -188,6 +195,9 @@ function notificationTargetType(row: NotificationRow): NotificationTargetType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notificationPath(row: NotificationRow): string {
|
function notificationPath(row: NotificationRow): string {
|
||||||
|
if (row.profileUserId !== null) {
|
||||||
|
return `/profile/${row.profileUserId}`;
|
||||||
|
}
|
||||||
if (row.lifePostId !== null) {
|
if (row.lifePostId !== null) {
|
||||||
return `/life/${row.lifePostId}`;
|
return `/life/${row.lifePostId}`;
|
||||||
}
|
}
|
||||||
@@ -198,7 +208,9 @@ function notificationPath(row: NotificationRow): string {
|
|||||||
function toNotificationItem(row: NotificationRow): NotificationItem {
|
function toNotificationItem(row: NotificationRow): NotificationItem {
|
||||||
const targetType = notificationTargetType(row);
|
const targetType = notificationTargetType(row);
|
||||||
const targetId =
|
const targetId =
|
||||||
targetType === 'discussion-comment'
|
targetType === 'profile-user'
|
||||||
|
? row.profileUserId
|
||||||
|
: targetType === 'discussion-comment'
|
||||||
? row.discussionCommentId
|
? row.discussionCommentId
|
||||||
: targetType === 'life-comment'
|
: targetType === 'life-comment'
|
||||||
? row.lifeCommentId
|
? row.lifeCommentId
|
||||||
@@ -213,6 +225,7 @@ function toNotificationItem(row: NotificationRow): NotificationItem {
|
|||||||
id: targetId ?? 0,
|
id: targetId ?? 0,
|
||||||
path: notificationPath(row),
|
path: notificationPath(row),
|
||||||
lifePostId: row.lifePostId,
|
lifePostId: row.lifePostId,
|
||||||
|
profileUserId: row.profileUserId,
|
||||||
lifeCommentId: row.lifeCommentId,
|
lifeCommentId: row.lifeCommentId,
|
||||||
discussionCommentId: row.discussionCommentId,
|
discussionCommentId: row.discussionCommentId,
|
||||||
entityType: row.entityType,
|
entityType: row.entityType,
|
||||||
@@ -458,6 +471,43 @@ export async function createLifePostReactionNotification(postId: number, actorUs
|
|||||||
await publishInsertedNotification(row);
|
await publishInsertedNotification(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createUserFollowNotification(actorUserId: number, followedUserId: number): Promise<void> {
|
||||||
|
const row = await queryOne<{ id: number; recipientUserId: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO notifications (
|
||||||
|
recipient_user_id,
|
||||||
|
actor_user_id,
|
||||||
|
type,
|
||||||
|
profile_user_id,
|
||||||
|
read_at,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
followed_user.id,
|
||||||
|
actor_user.id,
|
||||||
|
'user_follow',
|
||||||
|
actor_user.id,
|
||||||
|
NULL,
|
||||||
|
now(),
|
||||||
|
now()
|
||||||
|
FROM users actor_user
|
||||||
|
JOIN users followed_user ON followed_user.id = $2
|
||||||
|
WHERE actor_user.id = $1
|
||||||
|
AND actor_user.id <> followed_user.id
|
||||||
|
ON CONFLICT (recipient_user_id, actor_user_id, profile_user_id)
|
||||||
|
WHERE type = 'user_follow' AND actor_user_id IS NOT NULL AND profile_user_id IS NOT NULL
|
||||||
|
DO UPDATE SET read_at = NULL,
|
||||||
|
created_at = now(),
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING id, recipient_user_id AS "recipientUserId"
|
||||||
|
`,
|
||||||
|
[actorUserId, followedUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await publishInsertedNotification(row);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createApprovedCommentNotification(target: {
|
export async function createApprovedCommentNotification(target: {
|
||||||
type: ModerationTargetType;
|
type: ModerationTargetType;
|
||||||
id: number;
|
id: number;
|
||||||
@@ -613,6 +663,7 @@ export async function createModerationResultNotification(
|
|||||||
id: row.lifePostId,
|
id: row.lifePostId,
|
||||||
path: `/life/${row.lifePostId}`,
|
path: `/life/${row.lifePostId}`,
|
||||||
lifePostId: row.lifePostId,
|
lifePostId: row.lifePostId,
|
||||||
|
profileUserId: null,
|
||||||
lifeCommentId: null,
|
lifeCommentId: null,
|
||||||
discussionCommentId: null,
|
discussionCommentId: null,
|
||||||
entityType: null,
|
entityType: null,
|
||||||
@@ -688,6 +739,7 @@ export async function createModerationResultNotification(
|
|||||||
id: row.lifeCommentId,
|
id: row.lifeCommentId,
|
||||||
path: `/life/${row.lifePostId}`,
|
path: `/life/${row.lifePostId}`,
|
||||||
lifePostId: row.lifePostId,
|
lifePostId: row.lifePostId,
|
||||||
|
profileUserId: null,
|
||||||
lifeCommentId: row.lifeCommentId,
|
lifeCommentId: row.lifeCommentId,
|
||||||
discussionCommentId: null,
|
discussionCommentId: null,
|
||||||
entityType: null,
|
entityType: null,
|
||||||
@@ -764,6 +816,7 @@ export async function createModerationResultNotification(
|
|||||||
id: row.discussionCommentId,
|
id: row.discussionCommentId,
|
||||||
path: discussionEntityPath(row.entityType, row.entityId) ?? '/',
|
path: discussionEntityPath(row.entityType, row.entityId) ?? '/',
|
||||||
lifePostId: null,
|
lifePostId: null,
|
||||||
|
profileUserId: null,
|
||||||
lifeCommentId: null,
|
lifeCommentId: null,
|
||||||
discussionCommentId: row.discussionCommentId,
|
discussionCommentId: row.discussionCommentId,
|
||||||
entityType: row.entityType,
|
entityType: row.entityType,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
requestAiModerationReview,
|
requestAiModerationReview,
|
||||||
type AiModerationStatus
|
type AiModerationStatus
|
||||||
} from './aiModeration.ts';
|
} from './aiModeration.ts';
|
||||||
import { createLifePostReactionNotification } from './notifications.ts';
|
import { createLifePostReactionNotification, createUserFollowNotification } from './notifications.ts';
|
||||||
|
|
||||||
type QueryValue = string | string[] | undefined;
|
type QueryValue = string | string[] | undefined;
|
||||||
|
|
||||||
@@ -36,6 +36,32 @@ type DataToolsBundle = {
|
|||||||
scopes: DataToolScope[];
|
scopes: DataToolScope[];
|
||||||
data: Partial<Record<DataToolScope, DataToolScopeData>>;
|
data: Partial<Record<DataToolScope, DataToolScopeData>>;
|
||||||
};
|
};
|
||||||
|
type GlobalSearchGroupType =
|
||||||
|
| 'pokemon'
|
||||||
|
| 'habitats'
|
||||||
|
| 'items'
|
||||||
|
| 'ancient-artifacts'
|
||||||
|
| 'recipes'
|
||||||
|
| 'daily-checklist'
|
||||||
|
| 'life'
|
||||||
|
| 'users';
|
||||||
|
type GlobalSearchItem = {
|
||||||
|
id: number;
|
||||||
|
type: GlobalSearchGroupType;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
summary: string | null;
|
||||||
|
meta: string | null;
|
||||||
|
image: EntityImageValue | PokemonImage | null;
|
||||||
|
};
|
||||||
|
type GlobalSearchGroup = {
|
||||||
|
type: GlobalSearchGroupType;
|
||||||
|
items: GlobalSearchItem[];
|
||||||
|
};
|
||||||
|
type GlobalSearchResults = {
|
||||||
|
query: string;
|
||||||
|
groups: GlobalSearchGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
type TranslationField = 'name' | 'title' | 'details' | 'genus';
|
type TranslationField = 'name' | 'title' | 'details' | 'genus';
|
||||||
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
|
||||||
@@ -328,6 +354,7 @@ type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
|||||||
|
|
||||||
type LifePostFilters = {
|
type LifePostFilters = {
|
||||||
authorId?: number;
|
authorId?: number;
|
||||||
|
followedByUserId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type LifePostsPage = {
|
type LifePostsPage = {
|
||||||
@@ -370,9 +397,19 @@ type PublicProfileContribution = {
|
|||||||
lastContributedAt: Date | null;
|
lastContributedAt: Date | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends';
|
||||||
|
|
||||||
|
type PublicProfileSocial = {
|
||||||
|
followerCount: number;
|
||||||
|
followingCount: number;
|
||||||
|
friendCount: number;
|
||||||
|
viewerRelation: PublicProfileViewerRelation;
|
||||||
|
};
|
||||||
|
|
||||||
type PublicUserProfile = {
|
type PublicUserProfile = {
|
||||||
user: PublicProfileUser;
|
user: PublicProfileUser;
|
||||||
stats: PublicProfileStats;
|
stats: PublicProfileStats;
|
||||||
|
social: PublicProfileSocial;
|
||||||
contributions: PublicProfileContribution[];
|
contributions: PublicProfileContribution[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2411,6 +2448,197 @@ export async function listDailyChecklistItems(locale = defaultLocale) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function globalSearch(paramsQuery: QueryParams = {}, locale = defaultLocale): Promise<GlobalSearchResults> {
|
||||||
|
const search = asString(paramsQuery.query)?.trim() ?? asString(paramsQuery.search)?.trim() ?? '';
|
||||||
|
if (!search) {
|
||||||
|
return { query: '', groups: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
const limit = 5;
|
||||||
|
const pokemonName = localizedName('pokemon', 'p', locale);
|
||||||
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
|
const itemName = localizedName('items', 'i', locale);
|
||||||
|
const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale);
|
||||||
|
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
||||||
|
const artifactCategoryName = systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale);
|
||||||
|
const recipeItemName = localizedName('items', 'result_item', locale);
|
||||||
|
const recipeMaterialName = localizedName('items', 'material_item', 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([
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
p.id,
|
||||||
|
'pokemon' AS type,
|
||||||
|
${pokemonName} AS title,
|
||||||
|
'/pokemon/' || p.id AS url,
|
||||||
|
NULLIF(p.genus, '') AS summary,
|
||||||
|
'#' || p.display_id::text AS meta,
|
||||||
|
${pokemonImageJson('p')} AS image
|
||||||
|
FROM pokemon p
|
||||||
|
WHERE ${pokemonName} ILIKE $1
|
||||||
|
ORDER BY ${orderByEntity('p')}
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
),
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
h.id,
|
||||||
|
'habitats' AS type,
|
||||||
|
${habitatName} AS title,
|
||||||
|
'/habitats/' || h.id AS url,
|
||||||
|
NULL AS summary,
|
||||||
|
NULL AS meta,
|
||||||
|
${uploadedImageJson('h.image_path')} AS image
|
||||||
|
FROM habitats h
|
||||||
|
WHERE ${habitatName} ILIKE $1
|
||||||
|
ORDER BY ${orderByEntity('h')}
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
),
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
i.id,
|
||||||
|
'items' AS type,
|
||||||
|
${itemName} AS title,
|
||||||
|
'/items/' || i.id AS url,
|
||||||
|
NULLIF(i.details, '') AS summary,
|
||||||
|
(${itemCategoryName}->>'name') AS meta,
|
||||||
|
${uploadedImageJson('i.image_path')} AS image
|
||||||
|
FROM items i
|
||||||
|
WHERE ${itemName} ILIKE $1
|
||||||
|
ORDER BY i.display_id, ${orderByEntity('i')}
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
),
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
'ancient-artifacts' AS type,
|
||||||
|
${artifactName} AS title,
|
||||||
|
'/ancient-artifacts/' || a.id AS url,
|
||||||
|
NULLIF(a.details, '') AS summary,
|
||||||
|
(${artifactCategoryName}->>'name') AS meta,
|
||||||
|
${uploadedImageJson('a.image_path')} AS image
|
||||||
|
FROM ancient_artifacts a
|
||||||
|
WHERE ${artifactName} ILIKE $1
|
||||||
|
ORDER BY a.display_id, ${orderByEntity('a')}
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
),
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
'recipes' AS type,
|
||||||
|
${recipeItemName} AS title,
|
||||||
|
'/recipes/' || r.id AS url,
|
||||||
|
(
|
||||||
|
SELECT string_agg(material_rows.name, ' / ' ORDER BY material_rows.name)
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ${recipeMaterialName} AS name
|
||||||
|
FROM recipe_materials rm
|
||||||
|
JOIN items material_item ON material_item.id = rm.item_id
|
||||||
|
WHERE rm.recipe_id = r.id
|
||||||
|
) material_rows
|
||||||
|
) AS summary,
|
||||||
|
NULL AS meta,
|
||||||
|
${uploadedImageJson('result_item.image_path')} AS image
|
||||||
|
FROM recipes r
|
||||||
|
JOIN items result_item ON result_item.id = r.item_id
|
||||||
|
WHERE ${recipeItemName} ILIKE $1
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM recipe_materials rm
|
||||||
|
JOIN items material_item ON material_item.id = rm.item_id
|
||||||
|
WHERE rm.recipe_id = r.id
|
||||||
|
AND ${recipeMaterialName} ILIKE $1
|
||||||
|
)
|
||||||
|
ORDER BY ${orderByEntity('r')}
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
),
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
'daily-checklist' AS type,
|
||||||
|
${checklistTitle} AS title,
|
||||||
|
'/checklist' AS url,
|
||||||
|
NULL AS summary,
|
||||||
|
NULL AS meta,
|
||||||
|
NULL AS image
|
||||||
|
FROM daily_checklist_items c
|
||||||
|
WHERE ${checklistTitle} ILIKE $1
|
||||||
|
ORDER BY ${orderByEntity('c')}
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
),
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
lp.id,
|
||||||
|
'life' AS type,
|
||||||
|
LEFT(lp.body, 120) AS title,
|
||||||
|
'/life/' || lp.id AS url,
|
||||||
|
NULL AS summary,
|
||||||
|
${lifeCategoryName} AS meta,
|
||||||
|
NULL AS image
|
||||||
|
FROM life_posts lp
|
||||||
|
LEFT JOIN life_tags lc ON lc.id = lp.category_id
|
||||||
|
WHERE lp.deleted_at IS NULL
|
||||||
|
AND lp.ai_moderation_status = 'approved'
|
||||||
|
AND lp.body ILIKE $1
|
||||||
|
ORDER BY lp.created_at DESC, lp.id DESC
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
),
|
||||||
|
query<GlobalSearchItem>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
'users' AS type,
|
||||||
|
u.display_name AS title,
|
||||||
|
'/profile/' || u.id AS url,
|
||||||
|
NULL AS summary,
|
||||||
|
NULL AS meta,
|
||||||
|
NULL AS image
|
||||||
|
FROM users u
|
||||||
|
WHERE u.display_name ILIKE $1
|
||||||
|
ORDER BY lower(u.display_name), u.id
|
||||||
|
LIMIT $2
|
||||||
|
`,
|
||||||
|
[pattern, limit]
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groups: GlobalSearchGroup[] = [
|
||||||
|
{ type: 'pokemon', items: pokemon },
|
||||||
|
{ type: 'habitats', items: habitats },
|
||||||
|
{ type: 'items', items: items },
|
||||||
|
{ type: 'ancient-artifacts', items: artifacts },
|
||||||
|
{ type: 'recipes', items: recipes },
|
||||||
|
{ type: 'daily-checklist', items: checklist },
|
||||||
|
{ type: 'life', items: life },
|
||||||
|
{ type: 'users', items: users }
|
||||||
|
];
|
||||||
|
|
||||||
|
return { query: search, groups: groups.filter((group) => group.items.length > 0) };
|
||||||
|
}
|
||||||
|
|
||||||
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
|
async function getDailyChecklistItemById(id: number, locale = defaultLocale) {
|
||||||
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale);
|
||||||
return queryOne(
|
return queryOne(
|
||||||
@@ -2879,7 +3107,7 @@ function lifeCommentProjection(whereClause: string): string {
|
|||||||
lc.id,
|
lc.id,
|
||||||
lc.post_id AS "postId",
|
lc.post_id AS "postId",
|
||||||
lc.parent_comment_id AS "parentCommentId",
|
lc.parent_comment_id AS "parentCommentId",
|
||||||
CASE WHEN lc.deleted_at IS NULL THEN lc.body ELSE '' END AS body,
|
lc.body,
|
||||||
lc.deleted_at IS NOT NULL AS deleted,
|
lc.deleted_at IS NOT NULL AS deleted,
|
||||||
lc.ai_moderation_status AS "moderationStatus",
|
lc.ai_moderation_status AS "moderationStatus",
|
||||||
lc.ai_moderation_language_code AS "moderationLanguageCode",
|
lc.ai_moderation_language_code AS "moderationLanguageCode",
|
||||||
@@ -2887,16 +3115,36 @@ function lifeCommentProjection(whereClause: string): string {
|
|||||||
lc.created_at AS "createdAt",
|
lc.created_at AS "createdAt",
|
||||||
lc.created_at::text AS "createdAtCursor",
|
lc.created_at::text AS "createdAtCursor",
|
||||||
lc.updated_at AS "updatedAt",
|
lc.updated_at AS "updatedAt",
|
||||||
CASE
|
CASE WHEN comment_user.id IS NULL THEN NULL ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name) END AS author
|
||||||
WHEN lc.deleted_at IS NOT NULL OR comment_user.id IS NULL THEN NULL
|
|
||||||
ELSE json_build_object('id', comment_user.id, 'displayName', comment_user.display_name)
|
|
||||||
END AS author
|
|
||||||
FROM life_post_comments lc
|
FROM life_post_comments lc
|
||||||
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
|
LEFT JOIN users comment_user ON comment_user.id = lc.created_by_user_id
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleLifeCommentExpression(alias: string, ownerColumn: string, userParamIndex: number | null): string {
|
||||||
|
return userParamIndex !== null ? `(${alias}.deleted_at IS NULL OR ${ownerColumn} = $${userParamIndex})` : `${alias}.deleted_at IS NULL`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVisibleLifeCommentCondition(conditions: string[], params: unknown[], userId: number | null): void {
|
||||||
|
const userParamIndex = params.length + 1;
|
||||||
|
if (userId !== null) {
|
||||||
|
params.push(userId);
|
||||||
|
}
|
||||||
|
conditions.push(visibleLifeCommentExpression('lc', 'lc.created_by_user_id', userId === null ? null : userParamIndex));
|
||||||
|
conditions.push(`
|
||||||
|
(
|
||||||
|
lc.parent_comment_id IS NULL
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM life_post_comments parent_comment
|
||||||
|
WHERE parent_comment.id = lc.parent_comment_id
|
||||||
|
AND ${visibleLifeCommentExpression('parent_comment', 'parent_comment.created_by_user_id', userId === null ? null : userParamIndex)}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
function buildLifeCommentTree(rows: LifeCommentRow[]): LifeComment[] {
|
||||||
const comments = new Map<number, LifeComment>();
|
const comments = new Map<number, LifeComment>();
|
||||||
const topLevelComments: LifeComment[] = [];
|
const topLevelComments: LifeComment[] = [];
|
||||||
@@ -2939,6 +3187,7 @@ async function lifeCommentCountsForPosts(
|
|||||||
|
|
||||||
const params: unknown[] = [postIds];
|
const params: unknown[] = [postIds];
|
||||||
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
const conditions = ['lc.post_id = ANY($1::integer[])'];
|
||||||
|
addVisibleLifeCommentCondition(conditions, params, userId);
|
||||||
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(conditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
|
|
||||||
const rows = await query<{ postId: number; total: number }>(
|
const rows = await query<{ postId: number; total: number }>(
|
||||||
@@ -2970,6 +3219,7 @@ async function lifeCommentPreviewForPosts(
|
|||||||
|
|
||||||
const params: unknown[] = [postIds];
|
const params: unknown[] = [postIds];
|
||||||
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
|
const previewConditions = ['lc.post_id = ANY($1::integer[])', 'lc.parent_comment_id IS NULL'];
|
||||||
|
addVisibleLifeCommentCondition(previewConditions, params, userId);
|
||||||
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(previewConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
params.push(lifeCommentPreviewLimit);
|
params.push(lifeCommentPreviewLimit);
|
||||||
|
|
||||||
@@ -3029,6 +3279,7 @@ export async function listLifeComments(
|
|||||||
|
|
||||||
const params: unknown[] = [postId];
|
const params: unknown[] = [postId];
|
||||||
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
const topLevelConditions = ['lc.post_id = $1', 'lc.parent_comment_id IS NULL'];
|
||||||
|
addVisibleLifeCommentCondition(topLevelConditions, params, userId);
|
||||||
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(topLevelConditions, params, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
addModerationLanguageCondition(topLevelConditions, params, 'lc', languageCode);
|
||||||
|
|
||||||
@@ -3053,6 +3304,7 @@ export async function listLifeComments(
|
|||||||
? await (async () => {
|
? await (async () => {
|
||||||
const replyParams: unknown[] = [topLevelIds];
|
const replyParams: unknown[] = [topLevelIds];
|
||||||
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
const replyConditions = ['lc.parent_comment_id = ANY($1::integer[])'];
|
||||||
|
addVisibleLifeCommentCondition(replyConditions, replyParams, userId);
|
||||||
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(replyConditions, replyParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
addModerationLanguageCondition(replyConditions, replyParams, 'lc', languageCode);
|
||||||
return query<LifeCommentRow>(
|
return query<LifeCommentRow>(
|
||||||
@@ -3066,6 +3318,7 @@ export async function listLifeComments(
|
|||||||
: [];
|
: [];
|
||||||
const totalParams: unknown[] = [postId];
|
const totalParams: unknown[] = [postId];
|
||||||
const totalConditions = ['lc.post_id = $1'];
|
const totalConditions = ['lc.post_id = $1'];
|
||||||
|
addVisibleLifeCommentCondition(totalConditions, totalParams, userId);
|
||||||
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(totalConditions, totalParams, 'lc', 'lc.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
addModerationLanguageCondition(totalConditions, totalParams, 'lc', languageCode);
|
||||||
const total = await queryOne<{ total: number }>(
|
const total = await queryOne<{ total: number }>(
|
||||||
@@ -3312,6 +3565,18 @@ async function listLifePostsWithFilters(
|
|||||||
conditions.push(`lp.created_by_user_id = $${params.length}`);
|
conditions.push(`lp.created_by_user_id = $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.followedByUserId !== undefined) {
|
||||||
|
params.push(filters.followedByUserId);
|
||||||
|
conditions.push(`
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_follows uf
|
||||||
|
WHERE uf.follower_user_id = $${params.length}
|
||||||
|
AND uf.followed_user_id = lp.created_by_user_id
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
addModerationVisibilityCondition(conditions, params, 'lp', 'lp.created_by_user_id', userId, canViewAll);
|
||||||
addModerationLanguageCondition(conditions, params, 'lp', languageCode);
|
addModerationLanguageCondition(conditions, params, 'lp', languageCode);
|
||||||
|
|
||||||
@@ -3393,6 +3658,15 @@ export async function listLifePosts(
|
|||||||
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
|
return listLifePostsWithFilters(paramsQuery, userId, locale, {}, canViewAll);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listFollowingLifePosts(
|
||||||
|
userId: number,
|
||||||
|
paramsQuery: QueryParams = {},
|
||||||
|
locale = defaultLocale,
|
||||||
|
canViewAll = false
|
||||||
|
): Promise<LifePostsPage> {
|
||||||
|
return listLifePostsWithFilters(paramsQuery, userId, locale, { followedByUserId: userId }, canViewAll);
|
||||||
|
}
|
||||||
|
|
||||||
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
|
async function getPublicProfileUser(userIdValue: number): Promise<PublicProfileUser | null> {
|
||||||
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
|
const userId = requirePositiveInteger(userIdValue, 'server.validation.recordInvalid');
|
||||||
return queryOne<PublicProfileUser>(
|
return queryOne<PublicProfileUser>(
|
||||||
@@ -3412,7 +3686,68 @@ function publicContributionType(entityType: string): string {
|
|||||||
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
|
return entityType === 'daily-checklist-items' ? 'daily-checklist' : entityType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPublicUserProfile(userIdValue: number): Promise<PublicUserProfile | null> {
|
async function getPublicProfileSocial(userId: number, viewerUserId: number | null): Promise<PublicProfileSocial> {
|
||||||
|
const social = await queryOne<
|
||||||
|
Omit<PublicProfileSocial, 'viewerRelation'> & {
|
||||||
|
viewerFollows: boolean;
|
||||||
|
targetFollowsViewer: boolean;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE followed_user_id = $1), 0) AS "followerCount",
|
||||||
|
COALESCE((SELECT COUNT(*)::integer FROM user_follows WHERE follower_user_id = $1), 0) AS "followingCount",
|
||||||
|
COALESCE((
|
||||||
|
SELECT COUNT(*)::integer
|
||||||
|
FROM user_follows outgoing
|
||||||
|
WHERE outgoing.follower_user_id = $1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_follows incoming
|
||||||
|
WHERE incoming.follower_user_id = outgoing.followed_user_id
|
||||||
|
AND incoming.followed_user_id = $1
|
||||||
|
)
|
||||||
|
), 0) AS "friendCount",
|
||||||
|
CASE
|
||||||
|
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
|
||||||
|
ELSE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_follows
|
||||||
|
WHERE follower_user_id = $2::integer
|
||||||
|
AND followed_user_id = $1
|
||||||
|
)
|
||||||
|
END AS "viewerFollows",
|
||||||
|
CASE
|
||||||
|
WHEN $2::integer IS NULL OR $2::integer = $1 THEN false
|
||||||
|
ELSE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM user_follows
|
||||||
|
WHERE follower_user_id = $1
|
||||||
|
AND followed_user_id = $2::integer
|
||||||
|
)
|
||||||
|
END AS "targetFollowsViewer"
|
||||||
|
`,
|
||||||
|
[userId, viewerUserId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewerRelation =
|
||||||
|
social?.viewerFollows && social.targetFollowsViewer
|
||||||
|
? 'friends'
|
||||||
|
: social?.viewerFollows
|
||||||
|
? 'following'
|
||||||
|
: social?.targetFollowsViewer
|
||||||
|
? 'followed-by'
|
||||||
|
: 'none';
|
||||||
|
|
||||||
|
return {
|
||||||
|
followerCount: social?.followerCount ?? 0,
|
||||||
|
followingCount: social?.followingCount ?? 0,
|
||||||
|
friendCount: social?.friendCount ?? 0,
|
||||||
|
viewerRelation
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicUserProfile(userIdValue: number, viewerUserId: number | null = null): Promise<PublicUserProfile | null> {
|
||||||
const user = await getPublicProfileUser(userIdValue);
|
const user = await getPublicProfileUser(userIdValue);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
@@ -3479,6 +3814,8 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
|||||||
[user.id]
|
[user.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const social = await getPublicProfileSocial(user.id, viewerUserId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
stats: stats ?? {
|
stats: stats ?? {
|
||||||
@@ -3492,6 +3829,7 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
|||||||
lifeReactions: 0,
|
lifeReactions: 0,
|
||||||
discussionComments: 0
|
discussionComments: 0
|
||||||
},
|
},
|
||||||
|
social,
|
||||||
contributions: contributions.map((item) => ({
|
contributions: contributions.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
contentType: publicContributionType(item.contentType)
|
contentType: publicContributionType(item.contentType)
|
||||||
@@ -3499,6 +3837,57 @@ export async function getPublicUserProfile(userIdValue: number): Promise<PublicU
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function followUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
|
||||||
|
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
|
||||||
|
if (followerUserId === followedUserId) {
|
||||||
|
throw validationError('server.validation.cannotFollowSelf');
|
||||||
|
}
|
||||||
|
|
||||||
|
const followedUser = await getPublicProfileUser(followedUserId);
|
||||||
|
if (!followedUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await queryOne<{ inserted: boolean }>(
|
||||||
|
`
|
||||||
|
INSERT INTO user_follows (follower_user_id, followed_user_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (follower_user_id, followed_user_id) DO NOTHING
|
||||||
|
RETURNING true AS inserted
|
||||||
|
`,
|
||||||
|
[followerUserId, followedUser.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (inserted?.inserted === true) {
|
||||||
|
await createUserFollowNotification(followerUserId, followedUser.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getPublicUserProfile(followedUser.id, followerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unfollowUser(followerUserId: number, followedUserIdValue: number): Promise<PublicUserProfile | null> {
|
||||||
|
const followedUserId = requirePositiveInteger(followedUserIdValue, 'server.validation.recordInvalid');
|
||||||
|
if (followerUserId === followedUserId) {
|
||||||
|
throw validationError('server.validation.cannotFollowSelf');
|
||||||
|
}
|
||||||
|
|
||||||
|
const followedUser = await getPublicProfileUser(followedUserId);
|
||||||
|
if (!followedUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
DELETE FROM user_follows
|
||||||
|
WHERE follower_user_id = $1
|
||||||
|
AND followed_user_id = $2
|
||||||
|
`,
|
||||||
|
[followerUserId, followedUser.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getPublicUserProfile(followedUser.id, followerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
export async function listUserLifePosts(
|
export async function listUserLifePosts(
|
||||||
userIdValue: number,
|
userIdValue: number,
|
||||||
paramsQuery: QueryParams = {},
|
paramsQuery: QueryParams = {},
|
||||||
@@ -4163,6 +4552,29 @@ export async function deleteLifeComment(id: number, userId: number, allowAny = f
|
|||||||
return Boolean(result);
|
return Boolean(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function restoreLifeComment(id: number, userId: number) {
|
||||||
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
||||||
|
const result = await queryOne<{ id: number }>(
|
||||||
|
`
|
||||||
|
UPDATE life_post_comments
|
||||||
|
SET deleted_at = NULL, deleted_by_user_id = NULL, updated_at = now()
|
||||||
|
WHERE id = $1
|
||||||
|
AND created_by_user_id = $2
|
||||||
|
AND deleted_at IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM life_posts lp
|
||||||
|
WHERE lp.id = life_post_comments.post_id
|
||||||
|
AND lp.deleted_at IS NULL
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[commentId, userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return result ? getLifeCommentById(result.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
|
export async function retryLifeCommentModeration(id: number, userId: number, allowAny = false) {
|
||||||
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
const commentId = requirePositiveInteger(id, 'server.validation.commentInvalid');
|
||||||
const row = await queryOne<{ id: number }>(
|
const row = await queryOne<{ id: number }>(
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
exportAdminData,
|
exportAdminData,
|
||||||
fetchPokemonData,
|
fetchPokemonData,
|
||||||
fetchPokemonImageOptions,
|
fetchPokemonImageOptions,
|
||||||
|
followUser,
|
||||||
getAdminDataToolsSummary,
|
getAdminDataToolsSummary,
|
||||||
getAncientArtifact,
|
getAncientArtifact,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
@@ -73,6 +74,7 @@ import {
|
|||||||
getPokemon,
|
getPokemon,
|
||||||
getPublicUserProfile,
|
getPublicUserProfile,
|
||||||
getRecipe,
|
getRecipe,
|
||||||
|
globalSearch,
|
||||||
importAdminData,
|
importAdminData,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
listAncientArtifacts,
|
listAncientArtifacts,
|
||||||
@@ -80,6 +82,7 @@ import {
|
|||||||
listConfig,
|
listConfig,
|
||||||
listDailyChecklistItems,
|
listDailyChecklistItems,
|
||||||
listHabitats,
|
listHabitats,
|
||||||
|
listFollowingLifePosts,
|
||||||
listItems,
|
listItems,
|
||||||
listLifeComments,
|
listLifeComments,
|
||||||
listLanguages,
|
listLanguages,
|
||||||
@@ -102,6 +105,7 @@ import {
|
|||||||
retryEntityDiscussionCommentModeration,
|
retryEntityDiscussionCommentModeration,
|
||||||
retryLifeCommentModeration,
|
retryLifeCommentModeration,
|
||||||
retryLifePostModeration,
|
retryLifePostModeration,
|
||||||
|
restoreLifeComment,
|
||||||
setLifePostRating,
|
setLifePostRating,
|
||||||
setLifePostReaction,
|
setLifePostReaction,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
@@ -113,6 +117,7 @@ import {
|
|||||||
updateLifePost,
|
updateLifePost,
|
||||||
updatePokemon,
|
updatePokemon,
|
||||||
updateRecipe,
|
updateRecipe,
|
||||||
|
unfollowUser,
|
||||||
wipeAdminData
|
wipeAdminData
|
||||||
} from './queries.ts';
|
} from './queries.ts';
|
||||||
import {
|
import {
|
||||||
@@ -219,6 +224,10 @@ app.setErrorHandler(async (error, _request, reply) => {
|
|||||||
|
|
||||||
app.get('/health', async () => ({ ok: true }));
|
app.get('/health', async () => ({ ok: true }));
|
||||||
|
|
||||||
|
app.get('/api/search', async (request) =>
|
||||||
|
globalSearch(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
function getBearerToken(authorization: string | undefined): string | null {
|
function getBearerToken(authorization: string | undefined): string | null {
|
||||||
const [scheme, token] = authorization?.split(' ') ?? [];
|
const [scheme, token] = authorization?.split(' ') ?? [];
|
||||||
return scheme === 'Bearer' && token ? token : null;
|
return scheme === 'Bearer' && token ? token : null;
|
||||||
@@ -1178,7 +1187,30 @@ app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(reque
|
|||||||
|
|
||||||
app.get('/api/users/:id/profile', async (request, reply) => {
|
app.get('/api/users/:id/profile', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const profile = await getPublicUserProfile(Number(id));
|
const user = await optionalUser(request);
|
||||||
|
const profile = await getPublicUserProfile(Number(id), user?.id ?? null);
|
||||||
|
return profile ? { profile } : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/users/:id/follow', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const profile = await followUser(user.id, Number(id));
|
||||||
|
return profile ? { profile } : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:id/follow', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'users.follow', 'communityReaction');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const profile = await unfollowUser(user.id, Number(id));
|
||||||
return profile ? { profile } : notFound(reply, request);
|
return profile ? { profile } : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1233,6 +1265,20 @@ app.get('/api/life-posts', async (request) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/life-posts/following', async (request, reply) => {
|
||||||
|
const user = await requireVerifiedUser(request, reply);
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canViewAll = userHasPermission(user, 'life.posts.update-any') || userHasPermission(user, 'life.posts.delete-any');
|
||||||
|
return listFollowingLifePosts(
|
||||||
|
user.id,
|
||||||
|
request.query as Record<string, string | string[] | undefined>,
|
||||||
|
requestLocale(request),
|
||||||
|
canViewAll
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/life-posts/:id', async (request, reply) => {
|
app.get('/api/life-posts/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const user = await optionalUser(request);
|
const user = await optionalUser(request);
|
||||||
@@ -1414,6 +1460,16 @@ app.delete('/api/life-comments/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/life-comments/:id/restore', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.delete', 'communityWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const comment = await restoreLifeComment(Number(id), user.id);
|
||||||
|
return comment ? comment : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => {
|
app.post('/api/life-comments/:id/moderation/retry', async (request, reply) => {
|
||||||
const user = await requireAnyPermissionWithRateLimits(
|
const user = await requireAnyPermissionWithRateLimits(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type AppIcon
|
type AppIcon
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
import type { AuthUser, Language } from '../services/api';
|
import type { AuthUser, Language } from '../services/api';
|
||||||
|
import GlobalSearch from './GlobalSearch.vue';
|
||||||
import NotificationBell from './NotificationBell.vue';
|
import NotificationBell from './NotificationBell.vue';
|
||||||
import PokeBallMark from './PokeBallMark.vue';
|
import PokeBallMark from './PokeBallMark.vue';
|
||||||
import StatusBadge from './StatusBadge.vue';
|
import StatusBadge from './StatusBadge.vue';
|
||||||
@@ -271,6 +272,8 @@ onBeforeUnmount(() => {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GlobalSearch class="site-topbar__search" @navigate="closeSidebar" />
|
||||||
|
|
||||||
<div class="site-topbar__spacer" aria-hidden="true"></div>
|
<div class="site-topbar__spacer" aria-hidden="true"></div>
|
||||||
|
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
|
|||||||
281
frontend/src/components/GlobalSearch.vue
Normal file
281
frontend/src/components/GlobalSearch.vue
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { iconClose, iconSearch } from '../icons';
|
||||||
|
import { api, type GlobalSearchGroup, type GlobalSearchGroupType, type GlobalSearchItem } from '../services/api';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
navigate: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const router = useRouter();
|
||||||
|
const root = ref<HTMLElement | null>(null);
|
||||||
|
const input = ref<HTMLInputElement | null>(null);
|
||||||
|
const query = ref('');
|
||||||
|
const groups = ref<GlobalSearchGroup[]>([]);
|
||||||
|
const open = ref(false);
|
||||||
|
const mobileOpen = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
const failed = ref(false);
|
||||||
|
let searchTimeout: number | null = null;
|
||||||
|
let abortController: AbortController | null = null;
|
||||||
|
let requestId = 0;
|
||||||
|
|
||||||
|
const cleanQuery = computed(() => query.value.trim());
|
||||||
|
const hasResults = computed(() => groups.value.some((group) => group.items.length > 0));
|
||||||
|
const firstResult = computed(() => groups.value.find((group) => group.items.length > 0)?.items[0] ?? null);
|
||||||
|
const panelVisible = computed(() => open.value && cleanQuery.value !== '' && (loading.value || failed.value || groups.value.length > 0));
|
||||||
|
|
||||||
|
const groupLabels: Record<GlobalSearchGroupType, string> = {
|
||||||
|
pokemon: 'search.groups.pokemon',
|
||||||
|
habitats: 'search.groups.habitats',
|
||||||
|
items: 'search.groups.items',
|
||||||
|
'ancient-artifacts': 'search.groups.ancientArtifacts',
|
||||||
|
recipes: 'search.groups.recipes',
|
||||||
|
'daily-checklist': 'search.groups.dailyChecklist',
|
||||||
|
life: 'search.groups.life',
|
||||||
|
users: 'search.groups.users'
|
||||||
|
};
|
||||||
|
|
||||||
|
function clearSearchTimeout() {
|
||||||
|
if (searchTimeout !== null) {
|
||||||
|
window.clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortSearch() {
|
||||||
|
abortController?.abort();
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetResults() {
|
||||||
|
groups.value = [];
|
||||||
|
failed.value = false;
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSearch(value: string) {
|
||||||
|
const currentRequestId = ++requestId;
|
||||||
|
abortSearch();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortController = controller;
|
||||||
|
loading.value = true;
|
||||||
|
failed.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.globalSearch(value, controller.signal);
|
||||||
|
if (currentRequestId === requestId) {
|
||||||
|
groups.value = response.groups;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentRequestId === requestId) {
|
||||||
|
groups.value = [];
|
||||||
|
failed.value = true;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentRequestId === requestId) {
|
||||||
|
loading.value = false;
|
||||||
|
if (abortController === controller) {
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearch() {
|
||||||
|
clearSearchTimeout();
|
||||||
|
const value = cleanQuery.value;
|
||||||
|
if (!value) {
|
||||||
|
requestId += 1;
|
||||||
|
abortSearch();
|
||||||
|
resetResults();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestId += 1;
|
||||||
|
abortSearch();
|
||||||
|
loading.value = true;
|
||||||
|
failed.value = false;
|
||||||
|
searchTimeout = window.setTimeout(() => {
|
||||||
|
searchTimeout = null;
|
||||||
|
void runSearch(value);
|
||||||
|
}, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
open.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel() {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMobileSearch() {
|
||||||
|
mobileOpen.value = !mobileOpen.value;
|
||||||
|
openPanel();
|
||||||
|
if (mobileOpen.value) {
|
||||||
|
void nextTick(() => input.value?.focus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuery() {
|
||||||
|
query.value = '';
|
||||||
|
resetResults();
|
||||||
|
openPanel();
|
||||||
|
void nextTick(() => input.value?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubmit() {
|
||||||
|
const item = firstResult.value;
|
||||||
|
if (!item) {
|
||||||
|
openPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigateTo(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateTo(item: GlobalSearchItem) {
|
||||||
|
selectResult();
|
||||||
|
await router.push(item.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectResult() {
|
||||||
|
closePanel();
|
||||||
|
mobileOpen.value = false;
|
||||||
|
emit('navigate');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRootKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
closePanel();
|
||||||
|
input.value?.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocumentPointerDown(event: PointerEvent) {
|
||||||
|
if (root.value && !root.value.contains(event.target as Node)) {
|
||||||
|
closePanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLabel(type: GlobalSearchGroupType) {
|
||||||
|
return t(groupLabels[type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(query, scheduleSearch);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearSearchTimeout();
|
||||||
|
abortSearch();
|
||||||
|
document.removeEventListener('pointerdown', onDocumentPointerDown);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="root"
|
||||||
|
class="global-search"
|
||||||
|
:class="{ 'global-search--mobile-open': mobileOpen }"
|
||||||
|
@keydown="onRootKeydown"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="global-search__toggle"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('search.open')"
|
||||||
|
:aria-expanded="mobileOpen"
|
||||||
|
@click="toggleMobileSearch"
|
||||||
|
>
|
||||||
|
<Icon :icon="mobileOpen ? iconClose : iconSearch" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<form class="global-search__form" role="search" @submit.prevent="onSubmit">
|
||||||
|
<Icon :icon="iconSearch" class="ui-icon global-search__form-icon" aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
ref="input"
|
||||||
|
v-model="query"
|
||||||
|
class="global-search__input"
|
||||||
|
type="search"
|
||||||
|
:placeholder="t('search.placeholder')"
|
||||||
|
:aria-label="t('search.label')"
|
||||||
|
:aria-controls="panelVisible ? 'global-search-results' : undefined"
|
||||||
|
:aria-expanded="panelVisible"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="openPanel"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="cleanQuery"
|
||||||
|
class="global-search__clear"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('search.clear')"
|
||||||
|
@click="clearQuery"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconClose" class="ui-icon" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="panelVisible"
|
||||||
|
id="global-search-results"
|
||||||
|
class="global-search__panel"
|
||||||
|
:aria-busy="loading"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="global-search__skeleton" aria-hidden="true">
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-else-if="failed" class="global-search__message">{{ t('search.failed') }}</p>
|
||||||
|
<p v-else-if="!hasResults" class="global-search__message">{{ t('search.empty') }}</p>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<section
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.type"
|
||||||
|
class="global-search__group"
|
||||||
|
:aria-label="groupLabel(group.type)"
|
||||||
|
>
|
||||||
|
<h2 class="global-search__group-title">{{ groupLabel(group.type) }}</h2>
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="`${group.type}-${item.id}`"
|
||||||
|
class="global-search__result"
|
||||||
|
:to="item.url"
|
||||||
|
@click="selectResult"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.image"
|
||||||
|
class="global-search__result-image"
|
||||||
|
:src="item.image.url"
|
||||||
|
:alt="item.title"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span v-else class="global-search__result-mark" aria-hidden="true">
|
||||||
|
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<span class="global-search__result-copy">
|
||||||
|
<span class="global-search__result-title">{{ item.title }}</span>
|
||||||
|
<span v-if="item.summary || item.meta" class="global-search__result-meta">
|
||||||
|
<span v-if="item.meta">{{ item.meta }}</span>
|
||||||
|
<span v-if="item.summary">{{ item.summary }}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
iconBell,
|
iconBell,
|
||||||
iconCheck,
|
iconCheck,
|
||||||
iconComment,
|
iconComment,
|
||||||
|
iconProfile,
|
||||||
iconReactionFun,
|
iconReactionFun,
|
||||||
iconReactionHelpful,
|
iconReactionHelpful,
|
||||||
iconReactionLike,
|
iconReactionLike,
|
||||||
@@ -264,7 +265,8 @@ function targetLabel(type: NotificationTargetType) {
|
|||||||
const labels: Record<NotificationTargetType, string> = {
|
const labels: Record<NotificationTargetType, string> = {
|
||||||
'life-post': t('notifications.targetLifePost'),
|
'life-post': t('notifications.targetLifePost'),
|
||||||
'life-comment': t('notifications.targetLifeComment'),
|
'life-comment': t('notifications.targetLifeComment'),
|
||||||
'discussion-comment': t('notifications.targetDiscussionComment')
|
'discussion-comment': t('notifications.targetDiscussionComment'),
|
||||||
|
'profile-user': t('notifications.targetProfile')
|
||||||
};
|
};
|
||||||
return labels[type];
|
return labels[type];
|
||||||
}
|
}
|
||||||
@@ -285,6 +287,9 @@ function notificationText(notification: NotificationItem) {
|
|||||||
reaction: reactionLabel(notification.reactionType)
|
reaction: reactionLabel(notification.reactionType)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (notification.type === 'user_follow') {
|
||||||
|
return t('notifications.userFollow', { actor: actorName(notification) });
|
||||||
|
}
|
||||||
|
|
||||||
const target = targetLabel(notification.target.type);
|
const target = targetLabel(notification.target.type);
|
||||||
if (notification.moderationStatus === 'approved') {
|
if (notification.moderationStatus === 'approved') {
|
||||||
@@ -315,6 +320,9 @@ function notificationIcon(notification: NotificationItem) {
|
|||||||
if (notification.type === 'life_post_reaction') {
|
if (notification.type === 'life_post_reaction') {
|
||||||
return reactionIcon(notification.reactionType);
|
return reactionIcon(notification.reactionType);
|
||||||
}
|
}
|
||||||
|
if (notification.type === 'user_follow') {
|
||||||
|
return iconProfile;
|
||||||
|
}
|
||||||
return notification.moderationStatus === 'approved' ? iconCheck : iconWarning;
|
return notification.moderationStatus === 'approved' ? iconCheck : iconWarning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export const iconStar: AppIcon = 'mdi:star';
|
|||||||
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
export const iconStarOutline: AppIcon = 'mdi:star-outline';
|
||||||
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
export const iconSuccess: AppIcon = 'mdi:check-circle-outline';
|
||||||
export const iconTranslate: AppIcon = 'mdi:translate';
|
export const iconTranslate: AppIcon = 'mdi:translate';
|
||||||
|
export const iconUndo: AppIcon = 'mdi:undo';
|
||||||
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
export const iconUpload: AppIcon = 'mdi:upload-outline';
|
||||||
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
export const iconVersion: AppIcon = 'mdi:tag-outline';
|
||||||
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
export const iconWarning: AppIcon = 'mdi:alert-outline';
|
||||||
|
|||||||
@@ -318,6 +318,36 @@ export interface DailyChecklistItem {
|
|||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type GlobalSearchGroupType =
|
||||||
|
| 'pokemon'
|
||||||
|
| 'habitats'
|
||||||
|
| 'items'
|
||||||
|
| 'ancient-artifacts'
|
||||||
|
| 'recipes'
|
||||||
|
| 'daily-checklist'
|
||||||
|
| 'life'
|
||||||
|
| 'users';
|
||||||
|
|
||||||
|
export interface GlobalSearchItem {
|
||||||
|
id: number;
|
||||||
|
type: GlobalSearchGroupType;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
summary: string | null;
|
||||||
|
meta: string | null;
|
||||||
|
image: EntityImage | PokemonImage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalSearchGroup {
|
||||||
|
type: GlobalSearchGroupType;
|
||||||
|
items: GlobalSearchItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GlobalSearchResults {
|
||||||
|
query: string;
|
||||||
|
groups: GlobalSearchGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||||
|
|
||||||
export interface DataToolScopeSummary {
|
export interface DataToolScopeSummary {
|
||||||
@@ -344,9 +374,10 @@ export type NotificationType =
|
|||||||
| 'life_comment_reply'
|
| 'life_comment_reply'
|
||||||
| 'discussion_comment_reply'
|
| 'discussion_comment_reply'
|
||||||
| 'life_post_reaction'
|
| 'life_post_reaction'
|
||||||
|
| 'user_follow'
|
||||||
| 'moderation_result';
|
| 'moderation_result';
|
||||||
export type NotificationModerationStatus = Extract<AiModerationStatus, 'approved' | 'rejected' | 'failed'>;
|
export type NotificationModerationStatus = Extract<AiModerationStatus, 'approved' | 'rejected' | 'failed'>;
|
||||||
export type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment';
|
export type NotificationTargetType = 'life-post' | 'life-comment' | 'discussion-comment' | 'profile-user';
|
||||||
|
|
||||||
export interface LifePost {
|
export interface LifePost {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -438,6 +469,7 @@ export interface NotificationTarget {
|
|||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
lifePostId: number | null;
|
lifePostId: number | null;
|
||||||
|
profileUserId: number | null;
|
||||||
lifeCommentId: number | null;
|
lifeCommentId: number | null;
|
||||||
discussionCommentId: number | null;
|
discussionCommentId: number | null;
|
||||||
entityType: DiscussionEntityType | null;
|
entityType: DiscussionEntityType | null;
|
||||||
@@ -556,9 +588,19 @@ export interface PublicProfileContribution {
|
|||||||
lastContributedAt: string | null;
|
lastContributedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PublicProfileViewerRelation = 'none' | 'following' | 'followed-by' | 'friends';
|
||||||
|
|
||||||
|
export interface PublicProfileSocial {
|
||||||
|
followerCount: number;
|
||||||
|
followingCount: number;
|
||||||
|
friendCount: number;
|
||||||
|
viewerRelation: PublicProfileViewerRelation;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PublicUserProfile {
|
export interface PublicUserProfile {
|
||||||
user: PublicProfileUser;
|
user: PublicProfileUser;
|
||||||
stats: PublicProfileStats;
|
stats: PublicProfileStats;
|
||||||
|
social: PublicProfileSocial;
|
||||||
contributions: PublicProfileContribution[];
|
contributions: PublicProfileContribution[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1033,6 +1075,8 @@ async function deleteAndGetJson<T>(path: string): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
|
globalSearch: (query: string, signal?: AbortSignal) =>
|
||||||
|
getJson<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
|
||||||
languages: () => getJson<Language[]>('/api/languages'),
|
languages: () => getJson<Language[]>('/api/languages'),
|
||||||
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
projectUpdates: (params: ProjectUpdatesParams = {}) =>
|
||||||
getJson<ProjectUpdates>(
|
getJson<ProjectUpdates>(
|
||||||
@@ -1088,6 +1132,21 @@ export const api = {
|
|||||||
markAllNotificationsRead: () => sendJson<{ unreadCount: number }>('/api/notifications/read-all', 'POST', {}),
|
markAllNotificationsRead: () => sendJson<{ unreadCount: number }>('/api/notifications/read-all', 'POST', {}),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
|
publicProfile: (id: string | number) => getJson<{ profile: PublicUserProfile }>(`/api/users/${id}/profile`),
|
||||||
|
followUser: (id: string | number) => sendJson<{ profile: PublicUserProfile }>(`/api/users/${id}/follow`, 'PUT', {}),
|
||||||
|
unfollowUser: (id: string | number) => deleteAndGetJson<{ profile: PublicUserProfile }>(`/api/users/${id}/follow`),
|
||||||
|
followingLifePosts: (params: LifePostsParams = {}) =>
|
||||||
|
getJson<LifePostsPage>(
|
||||||
|
`/api/life-posts/following${buildQuery({
|
||||||
|
cursor: params.cursor ?? undefined,
|
||||||
|
limit: params.limit,
|
||||||
|
search: params.search,
|
||||||
|
categoryId: params.categoryId,
|
||||||
|
language: params.language,
|
||||||
|
gameVersionId: params.gameVersionId,
|
||||||
|
rateable: params.rateable === null ? undefined : params.rateable,
|
||||||
|
sort: params.sort
|
||||||
|
})}`
|
||||||
|
),
|
||||||
userLifePosts: (id: string | number, params: ProfileActivityParams = {}) =>
|
userLifePosts: (id: string | number, params: ProfileActivityParams = {}) =>
|
||||||
getJson<LifePostsPage>(
|
getJson<LifePostsPage>(
|
||||||
`/api/users/${id}/life-posts${buildQuery({
|
`/api/users/${id}/life-posts${buildQuery({
|
||||||
@@ -1177,6 +1236,7 @@ export const api = {
|
|||||||
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
sendJson<LifeComment>(`/api/life-posts/${postId}/comments/${commentId}/replies`, 'POST', payload),
|
||||||
retryLifeCommentModeration: (id: string | number) =>
|
retryLifeCommentModeration: (id: string | number) =>
|
||||||
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
|
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, 'POST', {}),
|
||||||
|
restoreLifeComment: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/restore`, 'POST', {}),
|
||||||
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
deleteLifeComment: (id: string | number) => deleteJson(`/api/life-comments/${id}`),
|
||||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
||||||
getJson<EntityDiscussionCommentsPage>(
|
getJson<EntityDiscussionCommentsPage>(
|
||||||
|
|||||||
@@ -159,6 +159,190 @@ svg {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-topbar__search {
|
||||||
|
flex: 0 1 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search {
|
||||||
|
position: relative;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__form {
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 3px 0 var(--line-strong);
|
||||||
|
padding: 0 10px;
|
||||||
|
transition:
|
||||||
|
border-color 0.14s ease,
|
||||||
|
box-shadow 0.14s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__form:focus-within {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
box-shadow: 0 3px 0 var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__form-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__input {
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__input::placeholder {
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__clear {
|
||||||
|
width: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__clear:hover {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__panel {
|
||||||
|
position: absolute;
|
||||||
|
inset: calc(100% + 8px) 0 auto 0;
|
||||||
|
z-index: 80;
|
||||||
|
max-height: min(70dvh, 620px);
|
||||||
|
overflow: auto;
|
||||||
|
border: 2px solid var(--line-strong);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow-raised);
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__group + .global-search__group {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__group-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result {
|
||||||
|
min-height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result:hover {
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result-image,
|
||||||
|
.global-search__result-mark {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-small);
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result-image {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result-mark {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result-title,
|
||||||
|
.global-search__result-meta {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result-title {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 0.94rem;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__result-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__message {
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__skeleton {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__skeleton span {
|
||||||
|
height: 48px;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: linear-gradient(90deg, var(--surface-soft), var(--line), var(--surface-soft));
|
||||||
|
background-size: 220% 100%;
|
||||||
|
animation: shimmer 1.4s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -6203,6 +6387,17 @@ button:disabled,
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-stat-strip--social {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-follow-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-stat-grid {
|
.profile-stat-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||||
}
|
}
|
||||||
@@ -6864,6 +7059,53 @@ button:disabled,
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.site-topbar__search {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search {
|
||||||
|
position: static;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__toggle {
|
||||||
|
width: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid var(--line);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__toggle:hover {
|
||||||
|
border-color: var(--pokemon-blue);
|
||||||
|
color: var(--pokemon-blue-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__form {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search--mobile-open .global-search__form {
|
||||||
|
position: fixed;
|
||||||
|
top: 68px;
|
||||||
|
right: 12px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 80;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search__panel {
|
||||||
|
position: fixed;
|
||||||
|
inset: 122px 12px auto 12px;
|
||||||
|
max-height: calc(100dvh - 138px);
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-actions {
|
.topbar-actions {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
iconReactionLike,
|
iconReactionLike,
|
||||||
iconReactionThanks,
|
iconReactionThanks,
|
||||||
iconReply,
|
iconReply,
|
||||||
|
iconUndo,
|
||||||
iconVersion,
|
iconVersion,
|
||||||
iconWarning
|
iconWarning
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
@@ -149,6 +150,25 @@ function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
|
|||||||
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceCommentInTree(items: LifeComment[], updated: LifeComment): boolean {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.id === updated.id) {
|
||||||
|
items[index] = { ...updated, replies: item.replies };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceCommentInTree(item.replies, updated)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadComments(reset = false) {
|
async function loadComments(reset = false) {
|
||||||
const currentPost = post.value;
|
const currentPost = post.value;
|
||||||
if (!currentPost || commentsLoading.value || commentsLoadingMore.value || (!reset && commentsLoaded.value && !commentsHasMore.value)) {
|
if (!currentPost || commentsLoading.value || commentsLoadingMore.value || (!reset && commentsLoaded.value && !commentsHasMore.value)) {
|
||||||
@@ -208,8 +228,12 @@ function canManageComment(comment: LifeComment) {
|
|||||||
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRestoreComment(comment: LifeComment) {
|
||||||
|
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
|
||||||
|
}
|
||||||
|
|
||||||
function canSeeCommentModeration(comment: LifeComment) {
|
function canSeeCommentModeration(comment: LifeComment) {
|
||||||
return currentUser.value?.id === comment.author?.id || can('life.comments.delete-any');
|
return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUseReactions() {
|
function canUseReactions() {
|
||||||
@@ -277,6 +301,10 @@ function moderationTone(status: AiModerationStatus) {
|
|||||||
return tones[status];
|
return tones[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moderationStatusVisible(status: AiModerationStatus) {
|
||||||
|
return status !== 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
function canRetryModeration(currentPost: LifePost) {
|
function canRetryModeration(currentPost: LifePost) {
|
||||||
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
|
return currentPost.moderationStatus !== 'approved' && currentPost.moderationStatus !== 'reviewing' && canManage(currentPost);
|
||||||
}
|
}
|
||||||
@@ -554,16 +582,39 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markCommentDeleted(items: LifeComment[], id: number): boolean {
|
function countCommentBranch(comment: LifeComment): number {
|
||||||
|
return 1 + comment.replies.reduce((total, reply) => total + countCommentBranch(reply), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCommentFromTree(items: LifeComment[], id: number): number {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (item.id === id) {
|
||||||
|
const removedCount = countCommentBranch(item);
|
||||||
|
items.splice(index, 1);
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedCount = removeCommentFromTree(item.replies, id);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOwnCommentDeleted(items: LifeComment[], id: number): boolean {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.id === id) {
|
if (item.id === id) {
|
||||||
item.deleted = true;
|
item.deleted = true;
|
||||||
item.body = '';
|
|
||||||
item.author = null;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (markCommentDeleted(item.replies, id)) {
|
if (markOwnCommentDeleted(item.replies, id)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,7 +632,19 @@ async function deleteComment(comment: LifeComment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteLifeComment(comment.id);
|
await api.deleteLifeComment(comment.id);
|
||||||
markCommentDeleted(comments.value, comment.id);
|
if (currentUser.value?.id === comment.author?.id) {
|
||||||
|
markOwnCommentDeleted(comments.value, comment.id);
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
} else {
|
||||||
|
const removedCount = removeCommentFromTree(comments.value, comment.id);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
commentsTotal.value = Math.max(0, commentsTotal.value - removedCount);
|
||||||
|
if (post.value) {
|
||||||
|
post.value.commentCount = commentsTotal.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (replyTargetId.value === comment.id) {
|
if (replyTargetId.value === comment.id) {
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
}
|
}
|
||||||
@@ -590,8 +653,24 @@ async function deleteComment(comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreComment(comment: LifeComment) {
|
||||||
|
const key = replyKey(comment.id);
|
||||||
|
commentBusyKey.value = key;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restored = await api.restoreLifeComment(comment.id);
|
||||||
|
replaceCommentInTree(comments.value, restored);
|
||||||
|
comments.value = [...comments.value];
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.restoreCommentFailed'));
|
||||||
|
} finally {
|
||||||
|
commentBusyKey.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function commentAuthorName(comment: LifeComment) {
|
function commentAuthorName(comment: LifeComment) {
|
||||||
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
return comment.author?.displayName ?? t('pages.life.byUnknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentInitial(comment: LifeComment) {
|
function commentInitial(comment: LifeComment) {
|
||||||
@@ -781,7 +860,12 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="life-post__review-actions">
|
<div class="life-post__review-actions">
|
||||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
<StatusBadge
|
||||||
|
v-if="moderationStatusVisible(post.moderationStatus)"
|
||||||
|
:label="moderationLabel(post.moderationStatus)"
|
||||||
|
:tone="moderationTone(post.moderationStatus)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="canRetryModeration(post)"
|
v-if="canRetryModeration(post)"
|
||||||
class="life-icon-button life-review-button"
|
class="life-icon-button life-review-button"
|
||||||
@@ -889,7 +973,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
<RouterLink v-if="comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||||
{{ comment.author.displayName }}
|
{{ comment.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||||
@@ -901,7 +985,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
<p class="life-comment__body">{{ comment.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -910,9 +994,9 @@ onUnmounted(() => {
|
|||||||
<span>{{ comment.moderationReason }}</span>
|
<span>{{ comment.moderationReason }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
|
||||||
<button
|
<button
|
||||||
v-if="canCommentOnPost"
|
v-if="!comment.deleted && canCommentOnPost"
|
||||||
class="life-icon-button life-icon-button--flat"
|
class="life-icon-button life-icon-button--flat"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.reply')"
|
:aria-label="t('pages.life.reply')"
|
||||||
@@ -931,6 +1015,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(comment)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(comment.id))"
|
||||||
|
@click="restoreComment(comment)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
|
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
|
||||||
@@ -977,7 +1072,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
<RouterLink v-if="reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||||
{{ reply.author.displayName }}
|
{{ reply.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||||
@@ -989,7 +1084,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
<p class="life-comment__body">{{ reply.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -997,8 +1092,9 @@ onUnmounted(() => {
|
|||||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||||
<span>{{ reply.moderationReason }}</span>
|
<span>{{ reply.moderationReason }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div v-if="canManageComment(reply)" class="life-comment__actions">
|
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
|
||||||
<button
|
<button
|
||||||
|
v-if="canManageComment(reply)"
|
||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@@ -1007,6 +1103,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(reply)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(reply.id))"
|
||||||
|
@click="restoreComment(reply)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
|
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
|
||||||
{{ commentErrors[replyKey(reply.id)] }}
|
{{ commentErrors[replyKey(reply.id)] }}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
iconReply,
|
iconReply,
|
||||||
iconSave,
|
iconSave,
|
||||||
iconSearch,
|
iconSearch,
|
||||||
|
iconUndo,
|
||||||
iconVersion,
|
iconVersion,
|
||||||
iconWarning
|
iconWarning
|
||||||
} from '../icons';
|
} from '../icons';
|
||||||
@@ -60,6 +61,7 @@ type LifeCommentPageState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
type LifePostSort = 'latest' | 'oldest' | 'top-rated';
|
||||||
|
type LifeFeedScope = 'all' | 'following';
|
||||||
|
|
||||||
const { locale, t } = useI18n();
|
const { locale, t } = useI18n();
|
||||||
const posts = ref<LifePost[]>([]);
|
const posts = ref<LifePost[]>([]);
|
||||||
@@ -78,6 +80,7 @@ const activeLanguageCode = ref('all');
|
|||||||
const activeGameVersionId = ref('all');
|
const activeGameVersionId = ref('all');
|
||||||
const activeRateableFilter = ref('all');
|
const activeRateableFilter = ref('all');
|
||||||
const activeSort = ref<LifePostSort>('latest');
|
const activeSort = ref<LifePostSort>('latest');
|
||||||
|
const activeFeedScope = ref<LifeFeedScope>('all');
|
||||||
const body = ref('');
|
const body = ref('');
|
||||||
const selectedCategoryId = ref('');
|
const selectedCategoryId = ref('');
|
||||||
const selectedGameVersionId = ref('');
|
const selectedGameVersionId = ref('');
|
||||||
@@ -180,6 +183,10 @@ const sortOptions = computed<Array<{ value: LifePostSort; label: string }>>(() =
|
|||||||
{ value: 'oldest', label: t('pages.life.sortOldest') },
|
{ value: 'oldest', label: t('pages.life.sortOldest') },
|
||||||
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
|
{ value: 'top-rated', label: t('pages.life.sortTopRated') }
|
||||||
]);
|
]);
|
||||||
|
const feedScopeOptions = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'all', label: t('pages.life.allFeed') },
|
||||||
|
{ value: 'following', label: t('pages.life.followingFeed') }
|
||||||
|
]);
|
||||||
const defaultLifeCategoryId = computed(() => {
|
const defaultLifeCategoryId = computed(() => {
|
||||||
const category = lifeCategories.value.find((item) => item.isDefault);
|
const category = lifeCategories.value.find((item) => item.isDefault);
|
||||||
return category ? String(category.id) : '';
|
return category ? String(category.id) : '';
|
||||||
@@ -195,6 +202,7 @@ async function loadCurrentUser() {
|
|||||||
|
|
||||||
if (!getAuthToken()) {
|
if (!getAuthToken()) {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
|
activeFeedScope.value = 'all';
|
||||||
authReady.value = true;
|
authReady.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,6 +212,7 @@ async function loadCurrentUser() {
|
|||||||
currentUser.value = response.user;
|
currentUser.value = response.user;
|
||||||
} catch {
|
} catch {
|
||||||
currentUser.value = null;
|
currentUser.value = null;
|
||||||
|
activeFeedScope.value = 'all';
|
||||||
setAuthToken(null);
|
setAuthToken(null);
|
||||||
} finally {
|
} finally {
|
||||||
authReady.value = true;
|
authReady.value = true;
|
||||||
@@ -264,7 +273,7 @@ async function loadPosts() {
|
|||||||
loadMorePaused.value = false;
|
loadMorePaused.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await api.lifePosts({
|
const params = {
|
||||||
limit: lifePostPageSize,
|
limit: lifePostPageSize,
|
||||||
search: searchQuery.value,
|
search: searchQuery.value,
|
||||||
categoryId: selectedFeedCategoryId.value,
|
categoryId: selectedFeedCategoryId.value,
|
||||||
@@ -272,7 +281,8 @@ async function loadPosts() {
|
|||||||
gameVersionId: selectedFeedGameVersionId.value,
|
gameVersionId: selectedFeedGameVersionId.value,
|
||||||
rateable: selectedRateableFilter.value,
|
rateable: selectedRateableFilter.value,
|
||||||
sort: activeSort.value
|
sort: activeSort.value
|
||||||
});
|
};
|
||||||
|
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
|
||||||
if (requestId !== postsRequestId) {
|
if (requestId !== postsRequestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -308,7 +318,7 @@ async function loadMorePosts() {
|
|||||||
loadError.value = '';
|
loadError.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const page = await api.lifePosts({
|
const params = {
|
||||||
cursor,
|
cursor,
|
||||||
limit: lifePostPageSize,
|
limit: lifePostPageSize,
|
||||||
search: searchQuery.value,
|
search: searchQuery.value,
|
||||||
@@ -317,7 +327,8 @@ async function loadMorePosts() {
|
|||||||
gameVersionId: selectedFeedGameVersionId.value,
|
gameVersionId: selectedFeedGameVersionId.value,
|
||||||
rateable: selectedRateableFilter.value,
|
rateable: selectedRateableFilter.value,
|
||||||
sort: activeSort.value
|
sort: activeSort.value
|
||||||
});
|
};
|
||||||
|
const page = activeFeedScope.value === 'following' ? await api.followingLifePosts(params) : await api.lifePosts(params);
|
||||||
if (requestId !== postsRequestId) {
|
if (requestId !== postsRequestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -446,7 +457,7 @@ async function submitPost() {
|
|||||||
replacePost(updated);
|
replacePost(updated);
|
||||||
} else {
|
} else {
|
||||||
const created = await api.createLifePost(payload());
|
const created = await api.createLifePost(payload());
|
||||||
if (activeSort.value !== 'latest') {
|
if (activeSort.value !== 'latest' || activeFeedScope.value === 'following') {
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
} else if (matchesCurrentFilters(created)) {
|
} else if (matchesCurrentFilters(created)) {
|
||||||
posts.value = [created, ...posts.value];
|
posts.value = [created, ...posts.value];
|
||||||
@@ -478,8 +489,12 @@ function canManageComment(comment: LifeComment) {
|
|||||||
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
return !comment.deleted && ((currentUser.value?.id === comment.author?.id && can('life.comments.delete')) || can('life.comments.delete-any'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRestoreComment(comment: LifeComment) {
|
||||||
|
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
|
||||||
|
}
|
||||||
|
|
||||||
function canSeeCommentModeration(comment: LifeComment) {
|
function canSeeCommentModeration(comment: LifeComment) {
|
||||||
return currentUser.value?.id === comment.author?.id || can('life.comments.delete-any');
|
return moderationStatusVisible(comment.moderationStatus) && (currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentKey(postId: number) {
|
function commentKey(postId: number) {
|
||||||
@@ -579,6 +594,10 @@ function moderationTone(status: AiModerationStatus) {
|
|||||||
return tones[status];
|
return tones[status];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function moderationStatusVisible(status: AiModerationStatus) {
|
||||||
|
return status !== 'approved';
|
||||||
|
}
|
||||||
|
|
||||||
function canRetryModeration(post: LifePost) {
|
function canRetryModeration(post: LifePost) {
|
||||||
return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post);
|
return post.moderationStatus !== 'approved' && post.moderationStatus !== 'reviewing' && canManage(post);
|
||||||
}
|
}
|
||||||
@@ -714,6 +733,25 @@ function mergeComments(existing: LifeComment[], incoming: LifeComment[]) {
|
|||||||
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
return [...existing, ...incoming.filter((comment) => !ids.has(comment.id))];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceCommentInTree(comments: LifeComment[], updated: LifeComment): boolean {
|
||||||
|
for (let index = 0; index < comments.length; index += 1) {
|
||||||
|
const comment = comments[index];
|
||||||
|
if (!comment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (comment.id === updated.id) {
|
||||||
|
comments[index] = { ...updated, replies: comment.replies };
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceCommentInTree(comment.replies, updated)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadComments(post: LifePost, reset = false) {
|
async function loadComments(post: LifePost, reset = false) {
|
||||||
const existing = commentPage(post);
|
const existing = commentPage(post);
|
||||||
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
|
if (existing.loading || existing.loadingMore || (!reset && existing.loaded && !existing.hasMore)) {
|
||||||
@@ -774,7 +812,7 @@ function isRatingBusy(postId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function commentAuthorName(comment: LifeComment) {
|
function commentAuthorName(comment: LifeComment) {
|
||||||
return comment.deleted ? t('pages.life.commentDeleted') : comment.author?.displayName ?? t('pages.life.byUnknown');
|
return comment.author?.displayName ?? t('pages.life.byUnknown');
|
||||||
}
|
}
|
||||||
|
|
||||||
function commentInitial(comment: LifeComment) {
|
function commentInitial(comment: LifeComment) {
|
||||||
@@ -1019,16 +1057,39 @@ async function submitReply(post: LifePost, comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function markCommentDeleted(comments: LifeComment[], id: number): boolean {
|
function countCommentTree(comment: LifeComment): number {
|
||||||
|
return 1 + comment.replies.reduce((total, reply) => total + countCommentTree(reply), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCommentFromTree(comments: LifeComment[], id: number): number {
|
||||||
|
for (let index = 0; index < comments.length; index += 1) {
|
||||||
|
const comment = comments[index];
|
||||||
|
if (!comment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (comment.id === id) {
|
||||||
|
const removedCount = countCommentTree(comment);
|
||||||
|
comments.splice(index, 1);
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedCount = removeCommentFromTree(comment.replies, id);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
return removedCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOwnCommentDeleted(comments: LifeComment[], id: number): boolean {
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
if (comment.id === id) {
|
if (comment.id === id) {
|
||||||
comment.deleted = true;
|
comment.deleted = true;
|
||||||
comment.body = '';
|
|
||||||
comment.author = null;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (markCommentDeleted(comment.replies, id)) {
|
if (markOwnCommentDeleted(comment.replies, id)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1046,7 +1107,24 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await api.deleteLifeComment(comment.id);
|
await api.deleteLifeComment(comment.id);
|
||||||
markCommentDeleted(commentsForPost(post), comment.id);
|
if (currentUser.value?.id === comment.author?.id) {
|
||||||
|
markOwnCommentDeleted(commentsForPost(post), comment.id);
|
||||||
|
updateCommentPage(post, (page) => ({
|
||||||
|
...page,
|
||||||
|
items: [...page.items]
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
const removedCount = removeCommentFromTree(commentsForPost(post), comment.id);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
const nextTotal = Math.max(0, commentCount(post) - removedCount);
|
||||||
|
post.commentCount = nextTotal;
|
||||||
|
updateCommentPage(post, (page) => ({
|
||||||
|
...page,
|
||||||
|
items: [...page.items],
|
||||||
|
total: nextTotal
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (replyTargetId.value === comment.id) {
|
if (replyTargetId.value === comment.id) {
|
||||||
cancelReply(comment.id);
|
cancelReply(comment.id);
|
||||||
}
|
}
|
||||||
@@ -1055,6 +1133,25 @@ async function deleteComment(post: LifePost, comment: LifeComment) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreComment(post: LifePost, comment: LifeComment) {
|
||||||
|
const key = replyKey(comment.id);
|
||||||
|
commentBusyKey.value = key;
|
||||||
|
clearCommentError(key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const restored = await api.restoreLifeComment(comment.id);
|
||||||
|
replaceCommentInTree(commentsForPost(post), restored);
|
||||||
|
updateCommentPage(post, (page) => ({
|
||||||
|
...page,
|
||||||
|
items: [...page.items]
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.restoreCommentFailed'));
|
||||||
|
} finally {
|
||||||
|
commentBusyKey.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatPostTime(value: string) {
|
function formatPostTime(value: string) {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) {
|
if (Number.isNaN(date.getTime())) {
|
||||||
@@ -1118,6 +1215,9 @@ watch(activeRateableFilter, () => {
|
|||||||
watch(activeSort, () => {
|
watch(activeSort, () => {
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
});
|
});
|
||||||
|
watch(activeFeedScope, () => {
|
||||||
|
void loadPosts();
|
||||||
|
});
|
||||||
watch(locale, () => {
|
watch(locale, () => {
|
||||||
void loadLanguages();
|
void loadLanguages();
|
||||||
void loadLifeCategories();
|
void loadLifeCategories();
|
||||||
@@ -1133,8 +1233,10 @@ onMounted(() => {
|
|||||||
void loadLifeCategories();
|
void loadLifeCategories();
|
||||||
void loadPosts();
|
void loadPosts();
|
||||||
removeAuthListener = onAuthTokenChange(() => {
|
removeAuthListener = onAuthTokenChange(() => {
|
||||||
void loadCurrentUser();
|
void (async () => {
|
||||||
void loadPosts();
|
await loadCurrentUser();
|
||||||
|
await loadPosts();
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1295,6 +1397,13 @@ onUnmounted(() => {
|
|||||||
@close="closeReactionUsersModal"
|
@close="closeReactionUsersModal"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
v-if="currentUser"
|
||||||
|
id="life-feed-scope"
|
||||||
|
v-model="activeFeedScope"
|
||||||
|
:tabs="feedScopeOptions"
|
||||||
|
: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')" />
|
<Tabs id="life-category-filter" v-model="activeCategoryId" :tabs="categoryFilterOptions" :label="t('pages.life.category')" />
|
||||||
|
|
||||||
@@ -1451,7 +1560,12 @@ onUnmounted(() => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="life-post__review-actions">
|
<div class="life-post__review-actions">
|
||||||
<StatusBadge :label="moderationLabel(post.moderationStatus)" :tone="moderationTone(post.moderationStatus)" compact />
|
<StatusBadge
|
||||||
|
v-if="moderationStatusVisible(post.moderationStatus)"
|
||||||
|
:label="moderationLabel(post.moderationStatus)"
|
||||||
|
:tone="moderationTone(post.moderationStatus)"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="canRetryModeration(post)"
|
v-if="canRetryModeration(post)"
|
||||||
class="life-icon-button life-review-button"
|
class="life-icon-button life-review-button"
|
||||||
@@ -1573,7 +1687,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(comment) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!comment.deleted && comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
<RouterLink v-if="comment.author" class="user-profile-link" :to="`/profile/${comment.author.id}`">
|
||||||
{{ comment.author.displayName }}
|
{{ comment.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
<strong v-else>{{ commentAuthorName(comment) }}</strong>
|
||||||
@@ -1585,7 +1699,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!comment.deleted" class="life-comment__body">{{ comment.body }}</p>
|
<p class="life-comment__body">{{ comment.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
v-if="canSeeCommentModeration(comment) && moderationReasonVisible(comment.moderationStatus, comment.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -1594,9 +1708,9 @@ onUnmounted(() => {
|
|||||||
<span>{{ comment.moderationReason }}</span>
|
<span>{{ comment.moderationReason }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="!comment.deleted" class="life-comment__actions">
|
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
|
||||||
<button
|
<button
|
||||||
v-if="canComment"
|
v-if="!comment.deleted && canComment"
|
||||||
class="life-icon-button life-icon-button--flat"
|
class="life-icon-button life-icon-button--flat"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.reply')"
|
:aria-label="t('pages.life.reply')"
|
||||||
@@ -1615,6 +1729,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(comment)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(comment.id))"
|
||||||
|
@click="restoreComment(post, comment)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
|
<p v-if="commentErrors[replyKey(comment.id)]" class="life-form__error" role="alert">
|
||||||
@@ -1661,7 +1786,7 @@ onUnmounted(() => {
|
|||||||
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
<div class="life-comment__avatar" aria-hidden="true">{{ commentInitial(reply) }}</div>
|
||||||
<div class="life-comment__content">
|
<div class="life-comment__content">
|
||||||
<div class="life-comment__meta">
|
<div class="life-comment__meta">
|
||||||
<RouterLink v-if="!reply.deleted && reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
<RouterLink v-if="reply.author" class="user-profile-link" :to="`/profile/${reply.author.id}`">
|
||||||
{{ reply.author.displayName }}
|
{{ reply.author.displayName }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
<strong v-else>{{ commentAuthorName(reply) }}</strong>
|
||||||
@@ -1673,7 +1798,7 @@ onUnmounted(() => {
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!reply.deleted" class="life-comment__body">{{ reply.body }}</p>
|
<p class="life-comment__body">{{ reply.body }}</p>
|
||||||
<p
|
<p
|
||||||
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
v-if="canSeeCommentModeration(reply) && moderationReasonVisible(reply.moderationStatus, reply.moderationReason)"
|
||||||
class="life-moderation-detail life-moderation-detail--comment"
|
class="life-moderation-detail life-moderation-detail--comment"
|
||||||
@@ -1681,8 +1806,9 @@ onUnmounted(() => {
|
|||||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||||
<span>{{ reply.moderationReason }}</span>
|
<span>{{ reply.moderationReason }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div v-if="canManageComment(reply)" class="life-comment__actions">
|
<div v-if="canManageComment(reply) || canRestoreComment(reply)" class="life-comment__actions">
|
||||||
<button
|
<button
|
||||||
|
v-if="canManageComment(reply)"
|
||||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-label="t('pages.life.deleteComment')"
|
:aria-label="t('pages.life.deleteComment')"
|
||||||
@@ -1691,6 +1817,17 @@ onUnmounted(() => {
|
|||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.deleteComment') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canRestoreComment(reply)"
|
||||||
|
class="life-icon-button life-icon-button--flat"
|
||||||
|
type="button"
|
||||||
|
:aria-label="t('pages.life.restoreComment')"
|
||||||
|
:disabled="isCommentBusy(replyKey(reply.id))"
|
||||||
|
@click="restoreComment(post, reply)"
|
||||||
|
>
|
||||||
|
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||||
|
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
|
<p v-if="commentErrors[replyKey(reply.id)]" class="life-form__error" role="alert">
|
||||||
{{ commentErrors[replyKey(reply.id)] }}
|
{{ commentErrors[replyKey(reply.id)] }}
|
||||||
|
|||||||
@@ -70,10 +70,12 @@ const commentFilter = ref<CommentFilter>('all');
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const passwordBusy = ref(false);
|
const passwordBusy = ref(false);
|
||||||
|
const followBusy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
const passwordMessage = ref('');
|
const passwordMessage = ref('');
|
||||||
const passwordErrorMessage = ref('');
|
const passwordErrorMessage = ref('');
|
||||||
|
const followErrorMessage = ref('');
|
||||||
const referralSummaryMessage = ref('');
|
const referralSummaryMessage = ref('');
|
||||||
const referralSummaryErrorMessage = ref('');
|
const referralSummaryErrorMessage = ref('');
|
||||||
const referralMessage = ref('');
|
const referralMessage = ref('');
|
||||||
@@ -111,6 +113,18 @@ const hasChanges = computed(() => {
|
|||||||
if (!user || !canShowAccount.value) return false;
|
if (!user || !canShowAccount.value) return false;
|
||||||
return trimmedDisplayName.value !== user.displayName;
|
return trimmedDisplayName.value !== user.displayName;
|
||||||
});
|
});
|
||||||
|
const canFollowProfile = computed(() => {
|
||||||
|
const user = currentUser.value;
|
||||||
|
const target = profile.value?.user;
|
||||||
|
return Boolean(user && target && user.id !== target.id && user.permissions.includes('users.follow'));
|
||||||
|
});
|
||||||
|
const followButtonLabel = computed(() => {
|
||||||
|
const relation = profile.value?.social.viewerRelation ?? 'none';
|
||||||
|
if (relation === 'friends') return t('pages.profile.friend');
|
||||||
|
if (relation === 'following') return t('pages.profile.following');
|
||||||
|
if (relation === 'followed-by') return t('pages.profile.followBack');
|
||||||
|
return t('pages.profile.follow');
|
||||||
|
});
|
||||||
const profileInitial = computed(() => {
|
const profileInitial = computed(() => {
|
||||||
const name = profile.value?.user.displayName.trim() || currentUser.value?.displayName.trim() || '';
|
const name = profile.value?.user.displayName.trim() || currentUser.value?.displayName.trim() || '';
|
||||||
return name.charAt(0).toUpperCase() || '#';
|
return name.charAt(0).toUpperCase() || '#';
|
||||||
@@ -176,6 +190,14 @@ const communityStats = computed(() => {
|
|||||||
{ label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 }
|
{ label: t('pages.profile.discussionComments'), value: stats?.discussionComments ?? 0 }
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
const socialStats = computed(() => {
|
||||||
|
const social = profile.value?.social;
|
||||||
|
return [
|
||||||
|
{ label: t('pages.profile.followers'), value: social?.followerCount ?? 0 },
|
||||||
|
{ label: t('pages.profile.followingCount'), value: social?.followingCount ?? 0 },
|
||||||
|
{ label: t('pages.profile.friends'), value: social?.friendCount ?? 0 }
|
||||||
|
];
|
||||||
|
});
|
||||||
const filteredContributions = computed(() => {
|
const filteredContributions = computed(() => {
|
||||||
const items = profile.value?.contributions ?? [];
|
const items = profile.value?.contributions ?? [];
|
||||||
if (contributionFilter.value === 'all') {
|
if (contributionFilter.value === 'all') {
|
||||||
@@ -280,6 +302,7 @@ async function loadProfile() {
|
|||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
passwordMessage.value = '';
|
passwordMessage.value = '';
|
||||||
passwordErrorMessage.value = '';
|
passwordErrorMessage.value = '';
|
||||||
|
followErrorMessage.value = '';
|
||||||
referralSummaryMessage.value = '';
|
referralSummaryMessage.value = '';
|
||||||
referralSummaryErrorMessage.value = '';
|
referralSummaryErrorMessage.value = '';
|
||||||
referralMessage.value = '';
|
referralMessage.value = '';
|
||||||
@@ -339,6 +362,27 @@ async function loadProfile() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleFollow() {
|
||||||
|
const target = profile.value?.user;
|
||||||
|
if (!target || !canFollowProfile.value || followBusy.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
followErrorMessage.value = '';
|
||||||
|
followBusy.value = true;
|
||||||
|
try {
|
||||||
|
const relation = profile.value?.social.viewerRelation ?? 'none';
|
||||||
|
const response = relation === 'following' || relation === 'friends'
|
||||||
|
? await api.unfollowUser(target.id)
|
||||||
|
: await api.followUser(target.id);
|
||||||
|
profile.value = response.profile;
|
||||||
|
} catch (error) {
|
||||||
|
followErrorMessage.value = error instanceof Error && error.message ? error.message : t('pages.profile.followFailed');
|
||||||
|
} finally {
|
||||||
|
followBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveProfile() {
|
async function saveProfile() {
|
||||||
message.value = '';
|
message.value = '';
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
@@ -680,6 +724,14 @@ onMounted(() => {
|
|||||||
:tone="currentUser.emailVerified ? 'success' : 'warning'"
|
:tone="currentUser.emailVerified ? 'success' : 'warning'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-if="canFollowProfile" class="profile-follow-actions">
|
||||||
|
<button class="ui-button ui-button--blue" type="button" :disabled="followBusy" @click="toggleFollow">
|
||||||
|
<Icon :icon="iconReferral" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ followButtonLabel }}
|
||||||
|
</button>
|
||||||
|
<StatusMessage v-if="followErrorMessage" variant="danger" :duration="0">{{ followErrorMessage }}</StatusMessage>
|
||||||
|
</div>
|
||||||
|
|
||||||
<dl class="profile-stat-strip">
|
<dl class="profile-stat-strip">
|
||||||
<div v-for="item in headlineStats" :key="item.label">
|
<div v-for="item in headlineStats" :key="item.label">
|
||||||
<dt>{{ item.label }}</dt>
|
<dt>{{ item.label }}</dt>
|
||||||
@@ -687,6 +739,13 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<dl class="profile-stat-strip profile-stat-strip--social">
|
||||||
|
<div v-for="item in socialStats" :key="item.label">
|
||||||
|
<dt>{{ item.label }}</dt>
|
||||||
|
<dd>{{ item.value }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<div v-if="canShowAccount && referral" class="profile-referral-summary">
|
<div v-if="canShowAccount && referral" class="profile-referral-summary">
|
||||||
<div>
|
<div>
|
||||||
<span>{{ t('pages.profile.referralCode') }}</span>
|
<span>{{ t('pages.profile.referralCode') }}</span>
|
||||||
|
|||||||
@@ -76,6 +76,24 @@ export const systemWordingMessages = {
|
|||||||
logout: 'Log out',
|
logout: 'Log out',
|
||||||
register: 'Register'
|
register: 'Register'
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
label: 'Search Pokopia Wiki',
|
||||||
|
placeholder: 'Search wiki',
|
||||||
|
open: 'Open search',
|
||||||
|
clear: 'Clear search',
|
||||||
|
empty: 'No matching results',
|
||||||
|
failed: 'Search is unavailable',
|
||||||
|
groups: {
|
||||||
|
pokemon: 'Pokemon',
|
||||||
|
habitats: 'Habitats',
|
||||||
|
items: 'Items',
|
||||||
|
ancientArtifacts: 'Ancient Artifacts',
|
||||||
|
recipes: 'Recipes',
|
||||||
|
dailyChecklist: 'Daily CheckList',
|
||||||
|
life: 'Life',
|
||||||
|
users: 'Users'
|
||||||
|
}
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
title: 'Notifications',
|
title: 'Notifications',
|
||||||
open: 'Open notifications',
|
open: 'Open notifications',
|
||||||
@@ -89,10 +107,12 @@ export const systemWordingMessages = {
|
|||||||
targetLifePost: 'Life post',
|
targetLifePost: 'Life post',
|
||||||
targetLifeComment: 'Life comment',
|
targetLifeComment: 'Life comment',
|
||||||
targetDiscussionComment: 'discussion comment',
|
targetDiscussionComment: 'discussion comment',
|
||||||
|
targetProfile: 'profile',
|
||||||
lifePostComment: '{actor} commented on your Life post',
|
lifePostComment: '{actor} commented on your Life post',
|
||||||
lifeCommentReply: '{actor} replied to your Life comment',
|
lifeCommentReply: '{actor} replied to your Life comment',
|
||||||
discussionCommentReply: '{actor} replied to your discussion comment',
|
discussionCommentReply: '{actor} replied to your discussion comment',
|
||||||
lifePostReaction: '{actor} reacted {reaction} to your Life post',
|
lifePostReaction: '{actor} reacted {reaction} to your Life post',
|
||||||
|
userFollow: '{actor} followed you',
|
||||||
moderationApproved: 'Your {target} passed review',
|
moderationApproved: 'Your {target} passed review',
|
||||||
moderationRejected: 'Your {target} did not pass review',
|
moderationRejected: 'Your {target} did not pass review',
|
||||||
moderationFailed: 'Review failed for your {target}'
|
moderationFailed: 'Review failed for your {target}'
|
||||||
@@ -487,6 +507,14 @@ export const systemWordingMessages = {
|
|||||||
passwordSaved: 'Password updated',
|
passwordSaved: 'Password updated',
|
||||||
passwordSaveFailed: 'Password update failed',
|
passwordSaveFailed: 'Password update failed',
|
||||||
savePassword: 'Save password',
|
savePassword: 'Save password',
|
||||||
|
follow: 'Follow',
|
||||||
|
followBack: 'Follow back',
|
||||||
|
following: 'Following',
|
||||||
|
friend: 'Friend',
|
||||||
|
followers: 'Followers',
|
||||||
|
followingCount: 'Following',
|
||||||
|
friends: 'Friends',
|
||||||
|
followFailed: 'Follow action failed',
|
||||||
joinedAt: 'Joined {date}',
|
joinedAt: 'Joined {date}',
|
||||||
lifePosts: 'Life posts',
|
lifePosts: 'Life posts',
|
||||||
lifeComments: 'Life comments',
|
lifeComments: 'Life comments',
|
||||||
@@ -831,6 +859,9 @@ export const systemWordingMessages = {
|
|||||||
languages: 'Languages',
|
languages: 'Languages',
|
||||||
allLanguages: 'All languages',
|
allLanguages: 'All languages',
|
||||||
allCategories: 'All',
|
allCategories: 'All',
|
||||||
|
feedScope: 'Feed scope',
|
||||||
|
allFeed: 'All feed',
|
||||||
|
followingFeed: 'Following',
|
||||||
allVersions: 'All versions',
|
allVersions: 'All versions',
|
||||||
versionFilter: 'Version',
|
versionFilter: 'Version',
|
||||||
ratingFilter: 'Rating',
|
ratingFilter: 'Rating',
|
||||||
@@ -892,10 +923,12 @@ export const systemWordingMessages = {
|
|||||||
deleteComment: 'Delete comment',
|
deleteComment: 'Delete comment',
|
||||||
deleteCommentConfirm: 'Delete this comment?',
|
deleteCommentConfirm: 'Delete this comment?',
|
||||||
commentDeleted: 'Comment deleted',
|
commentDeleted: 'Comment deleted',
|
||||||
|
restoreComment: 'Undo',
|
||||||
commentRequired: 'Please enter a comment.',
|
commentRequired: 'Please enter a comment.',
|
||||||
commentFailed: 'Comment failed',
|
commentFailed: 'Comment failed',
|
||||||
replyFailed: 'Reply failed',
|
replyFailed: 'Reply failed',
|
||||||
deleteCommentFailed: 'Delete comment failed',
|
deleteCommentFailed: 'Delete comment failed',
|
||||||
|
restoreCommentFailed: 'Undo failed',
|
||||||
publish: 'Post',
|
publish: 'Post',
|
||||||
publishing: 'Posting',
|
publishing: 'Posting',
|
||||||
update: 'Update',
|
update: 'Update',
|
||||||
@@ -1178,6 +1211,7 @@ export const systemWordingMessages = {
|
|||||||
invalidResetToken: 'The password reset link is invalid or expired',
|
invalidResetToken: 'The password reset link is invalid or expired',
|
||||||
currentPasswordInvalid: 'Current password is incorrect',
|
currentPasswordInvalid: 'Current password is incorrect',
|
||||||
invalidReferralCode: 'Referral code is invalid',
|
invalidReferralCode: 'Referral code is invalid',
|
||||||
|
cannotFollowSelf: 'You cannot follow yourself',
|
||||||
emailDeliveryUnavailable: 'Email delivery is temporarily unavailable. Please try again later.'
|
emailDeliveryUnavailable: 'Email delivery is temporarily unavailable. Please try again later.'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
@@ -1362,6 +1396,24 @@ export const systemWordingMessages = {
|
|||||||
logout: '退出',
|
logout: '退出',
|
||||||
register: '注册'
|
register: '注册'
|
||||||
},
|
},
|
||||||
|
search: {
|
||||||
|
label: '搜索 Pokopia Wiki',
|
||||||
|
placeholder: '搜索 Wiki',
|
||||||
|
open: '打开搜索',
|
||||||
|
clear: '清空搜索',
|
||||||
|
empty: '没有匹配结果',
|
||||||
|
failed: '搜索暂不可用',
|
||||||
|
groups: {
|
||||||
|
pokemon: 'Pokemon',
|
||||||
|
habitats: '栖息地',
|
||||||
|
items: '物品',
|
||||||
|
ancientArtifacts: 'Ancient Artifacts',
|
||||||
|
recipes: '材料单',
|
||||||
|
dailyChecklist: '每日 CheckList',
|
||||||
|
life: 'Life',
|
||||||
|
users: '用户'
|
||||||
|
}
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
title: '通知',
|
title: '通知',
|
||||||
open: '打开通知',
|
open: '打开通知',
|
||||||
@@ -1375,10 +1427,12 @@ export const systemWordingMessages = {
|
|||||||
targetLifePost: 'Life 动态',
|
targetLifePost: 'Life 动态',
|
||||||
targetLifeComment: 'Life 评论',
|
targetLifeComment: 'Life 评论',
|
||||||
targetDiscussionComment: '讨论评论',
|
targetDiscussionComment: '讨论评论',
|
||||||
|
targetProfile: '个人主页',
|
||||||
lifePostComment: '{actor} 评论了你的 Life 动态',
|
lifePostComment: '{actor} 评论了你的 Life 动态',
|
||||||
lifeCommentReply: '{actor} 回复了你的 Life 评论',
|
lifeCommentReply: '{actor} 回复了你的 Life 评论',
|
||||||
discussionCommentReply: '{actor} 回复了你的讨论评论',
|
discussionCommentReply: '{actor} 回复了你的讨论评论',
|
||||||
lifePostReaction: '{actor} 用 {reaction} Reaction 了你的 Life 动态',
|
lifePostReaction: '{actor} 用 {reaction} Reaction 了你的 Life 动态',
|
||||||
|
userFollow: '{actor} 关注了你',
|
||||||
moderationApproved: '你的{target}已审核通过',
|
moderationApproved: '你的{target}已审核通过',
|
||||||
moderationRejected: '你的{target}未通过审核',
|
moderationRejected: '你的{target}未通过审核',
|
||||||
moderationFailed: '你的{target}审核失败'
|
moderationFailed: '你的{target}审核失败'
|
||||||
@@ -1747,6 +1801,14 @@ export const systemWordingMessages = {
|
|||||||
passwordSaved: '密码已更新',
|
passwordSaved: '密码已更新',
|
||||||
passwordSaveFailed: '密码更新失败',
|
passwordSaveFailed: '密码更新失败',
|
||||||
savePassword: '保存密码',
|
savePassword: '保存密码',
|
||||||
|
follow: '关注',
|
||||||
|
followBack: '回关',
|
||||||
|
following: '已关注',
|
||||||
|
friend: '好友',
|
||||||
|
followers: '粉丝',
|
||||||
|
followingCount: '关注',
|
||||||
|
friends: '好友',
|
||||||
|
followFailed: '关注操作失败',
|
||||||
joinedAt: '加入于 {date}',
|
joinedAt: '加入于 {date}',
|
||||||
lifePosts: 'Life 动态',
|
lifePosts: 'Life 动态',
|
||||||
lifeComments: 'Life 评论',
|
lifeComments: 'Life 评论',
|
||||||
@@ -2091,6 +2153,9 @@ export const systemWordingMessages = {
|
|||||||
languages: '语言区',
|
languages: '语言区',
|
||||||
allLanguages: '全部语言',
|
allLanguages: '全部语言',
|
||||||
allCategories: '全部',
|
allCategories: '全部',
|
||||||
|
feedScope: '动态范围',
|
||||||
|
allFeed: '全部动态',
|
||||||
|
followingFeed: '关注动态',
|
||||||
allVersions: '全部版本',
|
allVersions: '全部版本',
|
||||||
versionFilter: '版本',
|
versionFilter: '版本',
|
||||||
ratingFilter: '评分',
|
ratingFilter: '评分',
|
||||||
@@ -2152,10 +2217,12 @@ export const systemWordingMessages = {
|
|||||||
deleteComment: '删除评论',
|
deleteComment: '删除评论',
|
||||||
deleteCommentConfirm: '确认删除这条评论?',
|
deleteCommentConfirm: '确认删除这条评论?',
|
||||||
commentDeleted: '评论已删除',
|
commentDeleted: '评论已删除',
|
||||||
|
restoreComment: '撤销',
|
||||||
commentRequired: '请输入评论内容。',
|
commentRequired: '请输入评论内容。',
|
||||||
commentFailed: '评论失败',
|
commentFailed: '评论失败',
|
||||||
replyFailed: '回复失败',
|
replyFailed: '回复失败',
|
||||||
deleteCommentFailed: '删除评论失败',
|
deleteCommentFailed: '删除评论失败',
|
||||||
|
restoreCommentFailed: '撤销失败',
|
||||||
publish: '发布',
|
publish: '发布',
|
||||||
publishing: '发布中',
|
publishing: '发布中',
|
||||||
update: '更新',
|
update: '更新',
|
||||||
@@ -2438,6 +2505,7 @@ export const systemWordingMessages = {
|
|||||||
invalidResetToken: '密码重置链接无效或已过期',
|
invalidResetToken: '密码重置链接无效或已过期',
|
||||||
currentPasswordInvalid: '当前密码不正确',
|
currentPasswordInvalid: '当前密码不正确',
|
||||||
invalidReferralCode: '邀请码无效',
|
invalidReferralCode: '邀请码无效',
|
||||||
|
cannotFollowSelf: '不能关注自己',
|
||||||
emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。'
|
emailDeliveryUnavailable: '邮件发送暂时不可用,请稍后再试。'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
|
|||||||
Reference in New Issue
Block a user