Compare commits

..

4 Commits

Author SHA1 Message Date
cd0f8868c3 feat(ui): implement compact grid layout for items and artifacts
Add compact tooltip mode to EntityCard component
Display 12-column icon grid on desktop for collections
Retain standard card layout with details on mobile devices
2026-05-04 22:19:36 +08:00
28f4e6032c refactor: remove display ID from items and ancient artifacts
Drop display_id column from items and ancient_artifacts tables
Remove display ID inputs, labels, and sorting logic across the stack

BREAKING CHANGE: behavior is not backward compatible.
2026-05-04 21:32:00 +08:00
2220d5d595 feat(dish): add dish management and public view
Add database schema, permissions, and API endpoints for dishes
Implement frontend views and admin management for dish data
2026-05-04 21:00:23 +08:00
2ff2519647 feat(comments): add sorting and liking functionality
Support sorting by oldest, latest, most-liked, and most-replied.
Implement like/unlike actions for Life and Entity Discussion comments.
2026-05-04 17:29:09 +08:00
25 changed files with 3377 additions and 237 deletions

View File

@@ -65,11 +65,16 @@
- 每日 CheckList Task - 每日 CheckList Task
- Life Category - Life Category
- Game Version - Game Version
- Dish Category
- Dish Flavor
- Dish
- 支持翻译的字段: - 支持翻译的字段:
- `name` - `name`
- `title` - `title`
- `details`Pokemon、物品和 Ancient Artifacts 的介绍 / 说明 - `details`Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
- `genus`:仅 Pokemon Genus 使用 - `genus`:仅 Pokemon Genus 使用
- `effect`Dish Category 的吃后效果
- `mosslaxEffect`Dish 给 Mosslax 吃之后的效果
- 实体仍保留基础 `name``title``details``genus` 字段,默认语言内容以基础字段为准。 - 实体仍保留基础 `name``title``details``genus` 字段,默认语言内容以基础字段为准。
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。 - API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。 - 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。
@@ -377,8 +382,9 @@
- 讨论回复只支持一层回复,不做无限嵌套。 - 讨论回复只支持一层回复,不做无限嵌套。
- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。 - 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。
- 被删除实体的讨论会随实体删除一并清理。 - 被删除实体的讨论会随实体删除一并清理。
- 讨论按创建时间正序展示。
- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items``nextCursor``hasMore``total` - 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items``nextCursor``hasMore``total`
- 讨论列表支持 `sort``oldest` 默认按创建时间正序;`latest` 按创建时间倒序;`most-liked` 按点赞数倒序;`most-replied` 按直接回复数倒序;同分时使用创建时间和 ID 保持稳定排序。排序只作用于顶层评论,回复始终按创建时间正序展示。
- 已注册并完成邮箱验证且拥有 `discussions.comments.like` 权限的用户可以点赞或取消点赞审核通过且未删除的实体讨论评论;每个用户对每条评论最多 1 个 Like。
- 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。 - 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。
- 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。 - 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。
- 审核状态包括:`unreviewed``reviewing``approved``rejected``failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。 - 审核状态包括:`unreviewed``reviewing``approved``rejected``failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
@@ -390,6 +396,7 @@
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。 - 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations` - 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
- API 对外只返回评论作者的 `id``displayName` - API 对外只返回评论作者的 `id``displayName`
- API 对外返回讨论评论的 `likeCount``replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
- API 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈、`deleted_at``deleted_by_user_id` 等内部字段。 - API 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈、`deleted_at``deleted_by_user_id` 等内部字段。
## AI 审核 ## AI 审核
@@ -586,7 +593,6 @@ Pokemon 详情页展示:
物品可配置: 物品可配置:
- Display ID用于物品和 Event Items 各自列表内展示与排序;`display_id``is_event_item` 组合唯一
- 名称 - 名称
- 介绍 - 介绍
- 是否为 Event Item`is_event_item` - 是否为 Event Item`is_event_item`
@@ -631,9 +637,10 @@ Items 与 Event Items 使用相同数据模型:
- 按分类展示为标签页 - 按分类展示为标签页
- 按用途筛选 - 按用途筛选
- 按标签筛选 - 按标签筛选
- Display ID 和自定义排序展示 - 按自定义排序展示
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、`#Display ID 名称` 和分类;不展示标签、入手方式或编辑元信息 - 物品列表桌面端使用 12 列紧凑 Grid每个格子只展示物品图标有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示
- 有用途的物品在卡片左上角以斜 Ribbon 展示用途名称 - 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类
- 物品列表不展示标签、入手方式或编辑元信息。
- 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。 - 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。
物品详情页展示: 物品详情页展示:
@@ -641,7 +648,6 @@ Items 与 Event Items 使用相同数据模型:
- 基本信息 - 基本信息
- 当前图标图片;未配置图标时展示默认物品标记占位符 - 当前图标图片;未配置图标时展示默认物品标记占位符
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图 - 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
- Display ID
- 介绍 - 介绍
- 分类 - 分类
- 用途 - 用途
@@ -660,7 +666,6 @@ Items 与 Event Items 使用相同数据模型:
Ancient Artifacts 是独立 Wiki 内容类型,可配置: Ancient Artifacts 是独立 Wiki 内容类型,可配置:
- Display ID用于展示与排序
- 名称 - 名称
- 介绍 - 介绍
- 图片:使用 Ancient Artifacts 上传目录,支持图片历史 - 图片:使用 Ancient Artifacts 上传目录,支持图片历史
@@ -677,12 +682,13 @@ Ancient Artifacts 列表功能:
- 搜索 - 搜索
- 按分类展示为标签页 - 按分类展示为标签页
- 按标签筛选 - 按标签筛选
- Display ID 和自定义排序展示 - 按自定义排序展示
- 列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,展示图片 / 默认 Ancient Artifact 标记`#Display ID 名称` 和分类;不展示编辑元信息 - 列表桌面端使用 12 列紧凑 Grid每个格子只展示图片 / 默认 Ancient Artifact 标记;名称通过 hover / focus Tooltip 展示
- 列表移动端保持常规卡片布局,展示图片 / 默认 Ancient Artifact 标记、名称和分类。
- 列表不展示编辑元信息。
Ancient Artifacts 详情页展示: Ancient Artifacts 详情页展示:
- Display ID
- 名称 - 名称
- 图片;未配置图片时展示默认 Ancient Artifact 标记 - 图片;未配置图片时展示默认 Ancient Artifact 标记
- 介绍 - 介绍
@@ -712,8 +718,8 @@ Ancient Artifacts 详情页展示:
- 独立于物品列表展示 - 独立于物品列表展示
- 按结果物品分类展示 - 按结果物品分类展示
-结果物品 Display ID 和自定义排序展示 - 按自定义排序展示
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、`#Display ID 名称` 和分类;不展示编辑元信息。 - 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、名称和分类;不展示编辑元信息。
- 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。 - 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。
- Create Recipe 按钮展示在结果物品名称下方;已有材料单的卡片保留同等按钮空间但不显示按钮;标记为无材料单的物品展示禁用按钮;可创建材料单的物品展示可点击按钮并进入创建流程。 - Create Recipe 按钮展示在结果物品名称下方;已有材料单的卡片保留同等按钮空间但不显示按钮;标记为无材料单的物品展示禁用按钮;可创建材料单的物品展示可点击按钮并进入创建流程。
@@ -727,6 +733,43 @@ Ancient Artifacts 详情页展示:
- 讨论 - 讨论
- 编辑历史 - 编辑历史
## Dish
Dish 是公开浏览的料理资料入口,按可配置分类组织。
Dish Category 可配置:
- 名称
- 厨具:关联 Items
- 主材料:关联 Items必填
- 吃了之后的效果
- 总数所需材料数量:最小值为 2
- 翻译
- 排序
Dish 可配置:
- 所属 Dish Category
- 菜肴:关联 Items
- 味道:使用 System Config 中可配置的 Dish Flavor
- 副材料:关联 Items可选
- 第二副材料:关联 Items仅当所属分类的总数所需材料数量大于 2 时可配置
- Pokemon 特征:可选,复用现有特长配置
- 给苔藓卡比兽Mosslax吃之后的效果
- 翻译
- 排序
Dish 页面功能:
- `/dish` 是公开浏览入口。
- 分类使用 Tabs 展示。
- `/dish` 可直接添加、编辑和删除 Dish Category 与 Dish写入入口按 `dish.*` 权限展示,后端仍做权限校验。
- 每个分类第一行展示分类名、厨具、主材料和总数所需材料数量;第二行展示吃后效果。
- 每个菜肴展示菜肴物品、味道、可选副材料、可选第二副材料、可选 Pokemon 特征和 Mosslax 效果。
- Item、特长和 Dish Flavor 名称按当前语言解析Dish Category 名称、吃后效果和 Dish Mosslax 效果按当前语言解析。
- Dish 公开 API 只返回浏览需要的 Item、特长、材料、效果和审计字段不返回内部字段、权限、token/hash 或调试信息。
- Dish 分类和菜肴的创建、更新、删除、排序必须记录编辑历史和编辑者信息。
## 栖息地 ## 栖息地
栖息地可配置: 栖息地可配置:
@@ -828,7 +871,9 @@ Life Post 可配置:
- 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。 - 已软删除的 Life Post 详情页返回未找到,不公开软删除字段。
- 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。 - 每条 Life Post 默认只展示评论入口与评论数量;评论列表、回复和评论输入默认折叠,用户点击后展开。
- Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。 - Life Post 详情页默认展示该 Post 的评论区,并使用独立分页接口继续加载完整评论列表。
- Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口按顶层评论正序读取,每页顶层评论携带其一层回复。 - Life Feed 只随每条 Life Post 返回评论总数和最近少量评论预览;完整评论列表在展开评论区后通过独立分页接口读取,每页顶层评论携带其一层回复。
- Life Comment 列表支持 `sort``oldest` 默认按创建时间正序;`latest` 按创建时间倒序;`most-liked` 按点赞数倒序;`most-replied` 按直接回复数倒序;同分时使用创建时间和 ID 保持稳定排序。排序只作用于顶层评论,回复始终按创建时间正序展示。
- 已注册并完成邮箱验证且拥有 `life.comments.like` 权限的用户可以点赞或取消点赞审核通过且未删除的 Life Comment每个用户对每条评论最多 1 个 Like。
- 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like` - 已注册并完成邮箱验证且拥有 `life.reactions.set` 权限的用户可以对每条 Life Post 选择一个 Reaction普通点击默认设置 `like`,再次点击 `like` 会取消,当前为其他 Reaction 时普通点击会替换为 `like`
- Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。 - Life Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。 - 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。
@@ -850,7 +895,7 @@ Life Post 可配置:
- 新增或更新 Life Post 后先进入不可公开状态AI 审核通过后才出现在普通公开 Feed。 - 新增或更新 Life Post 后先进入不可公开状态AI 审核通过后才出现在普通公开 Feed。
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。 - Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。 - 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed``rejected``failed` 这类非进行中且未通过状态可触发重新审核。 - `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed``rejected``failed` 这类非进行中且未通过状态可触发重新审核API 也必须拒绝对 `reviewing``approved` 评论重新审核
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations` - Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`
API 暴露边界: API 暴露边界:
@@ -861,11 +906,12 @@ API 暴露边界:
- Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。 - Life Post Rating 只返回 `ratingAverage``ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
- Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。 - Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。
- Life Comment 作者信息只返回 `id``displayName` - Life Comment 作者信息只返回 `id``displayName`
- Life Comment 只返回 `likeCount``replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
- Life Post 列表和详情中的 Life Reaction 只返回按类型汇总的数量和当前用户自己的 Reaction不内嵌其他用户明细。 - Life Post 列表和详情中的 Life Reaction 只返回按类型汇总的数量和当前用户自己的 Reaction不内嵌其他用户明细。
- Life Reaction 用户列表 API 只返回公开用户摘要 `id``displayName``reactionType``reactedAt`不返回邮箱、角色、权限、token/hash、内部审计或其他用户隐私字段。 - Life Reaction 用户列表 API 只返回公开用户摘要 `id``displayName``reactionType``reactedAt`不返回邮箱、角色、权限、token/hash、内部审计或其他用户隐私字段。
- Life Post 列表 API 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount``commentPreview`,不内嵌完整评论列表。 - Life Post 列表 API 返回分页结果:`items``nextCursor``hasMore``cursor` 是不透明分页令牌。每个 Life Post 的评论字段只包含已公开或当前用户可见评论的 `commentCount``commentPreview`,不内嵌完整评论列表。
- Life Post 详情 API 返回单条 Life Post字段边界与列表项一致评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。 - Life Post 详情 API 返回单条 Life Post字段边界与列表项一致评论字段仍只包含 `commentCount` 和少量 `commentPreview`,完整评论通过评论分页接口读取。
- Life Comment 列表 API 返回分页结果:`items``nextCursor``hasMore``total``cursor` 是不透明分页令牌;普通访客只读取审核通过评论。 - Life Comment 列表 API 返回分页结果:`items``nextCursor``hasMore``total``cursor` 是不透明分页令牌;普通访客只读取审核通过评论;支持 `sort``oldest``latest``most-liked``most-replied`
- Life Comment 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情。 - Life Comment 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情。
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈或不必要的审计 payload。 - API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈或不必要的审计 payload。
- API 不返回 Life Post 的 `deleted_at``deleted_by_user_id` 等内部软删除字段。 - API 不返回 Life Post 的 `deleted_at``deleted_by_user_id` 等内部软删除字段。
@@ -876,7 +922,6 @@ API 暴露边界:
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力 以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力
- Automation未来用于分享自动化基地亦称工厂创建方案、材料产出、所需 Pokemon、生产顺序和共同喜好物品。 - Automation未来用于分享自动化基地亦称工厂创建方案、材料产出、所需 Pokemon、生产顺序和共同喜好物品。
- Dish
- Events - Events
- Actions游戏内快捷动作例如挥手、跳舞等。 - Actions游戏内快捷动作例如挥手、跳舞等。
- Dream Island - Dream Island
@@ -1009,18 +1054,19 @@ API 暴露边界:
- `GET /api/ancient-artifacts/:id` - `GET /api/ancient-artifacts/:id`
- `GET /api/recipes` - `GET /api/recipes`
- `GET /api/recipes/:id` - `GET /api/recipes/:id`
- `GET /api/dish`
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort``latest``oldest``top-rated` - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `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/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` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied`
- `GET /api/users/:id/profile`:读取公开用户 Profile 摘要、Wiki 贡献统计、公开社区统计和公开 Follow 统计;登录用户读取时返回自己与目标用户的关系状态。 - `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。 - `PUT /api/users/:id/follow`:需要 `users.follow`Follow 指定用户并返回更新后的公开 Profile。
- `DELETE /api/users/:id/follow`:需要 `users.follow`Unfollow 指定用户并返回更新后的公开 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` 按审核语言区筛选;支持 `sort``oldest``latest``most-liked``most-replied``entityType` 支持 `pokemon``items``recipes``habitats``ancient-artifacts`
认证 API 认证 API
@@ -1075,11 +1121,17 @@ API 暴露边界:
- `DELETE /api/life-comments/:id` - `DELETE /api/life-comments/:id`
- `POST /api/life-comments/:id/restore` - `POST /api/life-comments/:id/restore`
- `POST /api/life-comments/:id/moderation/retry` - `POST /api/life-comments/:id/moderation/retry`
- Life Comment 的点赞和取消点赞需要 `life.comments.like` 权限。
- `PUT /api/life-comments/:id/like`
- `DELETE /api/life-comments/:id/like`
- 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。 - 实体讨论评论的创建、回复,以及作者本人对评论的删除,需要对应 `discussions.comments.*` 权限;管理他人内容需要对应 `*-any` 权限。
- `POST /api/discussions/:entityType/:entityId/comments` - `POST /api/discussions/:entityType/:entityId/comments`
- `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies` - `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies`
- `DELETE /api/discussions/comments/:id` - `DELETE /api/discussions/comments/:id`
- `POST /api/discussions/comments/:id/moderation/retry` - `POST /api/discussions/comments/:id/moderation/retry`
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
- `PUT /api/discussions/comments/:id/like`
- `DELETE /api/discussions/comments/:id/like`
- Life Reaction 的设置、替换和取消。 - Life Reaction 的设置、替换和取消。
- `PUT /api/life-posts/:id/reaction` - `PUT /api/life-posts/:id/reaction`
- `DELETE /api/life-posts/:id/reaction` - `DELETE /api/life-posts/:id/reaction`

View File

@@ -35,12 +35,15 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'habitats', 'habitats',
'daily-checklist-items', 'daily-checklist-items',
'life-tags', 'life-tags',
'game-versions' 'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
) )
), ),
entity_id integer NOT NULL, entity_id integer NOT NULL,
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE, locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')), field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')),
value text NOT NULL, value text NOT NULL,
PRIMARY KEY (entity_type, entity_id, locale, field_name) PRIMARY KEY (entity_type, entity_id, locale, field_name)
); );
@@ -68,10 +71,21 @@ ALTER TABLE entity_translations
'habitats', 'habitats',
'daily-checklist-items', 'daily-checklist-items',
'life-tags', 'life-tags',
'game-versions' 'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
) )
); );
ALTER TABLE entity_translations
DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
ALTER TABLE entity_translations
ADD CONSTRAINT entity_translations_field_name_check CHECK (
field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')
);
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE, email text NOT NULL UNIQUE,
@@ -291,6 +305,10 @@ VALUES
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true), ('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true), ('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true), ('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true),
('dish.create', 'Create Dish records', 'Create Dish categories and dish records.', 'Dish', true),
('dish.update', 'Update Dish records', 'Edit Dish categories and dish records.', 'Dish', true),
('dish.delete', 'Delete Dish records', 'Delete Dish categories and dish records.', 'Dish', true),
('dish.order', 'Order Dish records', 'Reorder Dish categories and dish records.', 'Dish', true),
('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true), ('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true),
('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true), ('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true),
('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true), ('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true),
@@ -299,12 +317,14 @@ VALUES
('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true), ('life.comments.create', 'Create Life comments', 'Create Life comments and replies.', 'Life', true),
('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true), ('life.comments.delete', 'Delete own Life comments', 'Delete own Life comments.', 'Life', true),
('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true), ('life.comments.delete-any', 'Delete any Life comment', 'Delete any Life comment.', 'Life', true),
('life.comments.like', 'Like Life comments', 'Like and unlike Life comments.', 'Life', true),
('life.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), ('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),
('discussions.comments.like', 'Like discussion comments', 'Like and unlike entity discussion comments.', 'Discussions', true)
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
INSERT INTO roles (key, name, description, level, enabled, system_role) INSERT INTO roles (key, name, description, level, enabled, system_role)
@@ -383,6 +403,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'recipes.update', 'recipes.update',
'recipes.delete', 'recipes.delete',
'recipes.order', 'recipes.order',
'dish.create',
'dish.update',
'dish.delete',
'dish.order',
'life.posts.create', 'life.posts.create',
'life.posts.update', 'life.posts.update',
'life.posts.delete', 'life.posts.delete',
@@ -391,12 +415,14 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.comments.create', 'life.comments.create',
'life.comments.delete', 'life.comments.delete',
'life.comments.delete-any', 'life.comments.delete-any',
'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete', 'discussions.comments.delete',
'discussions.comments.delete-any' 'discussions.comments.delete-any',
'discussions.comments.like'
]) ])
WHERE r.key = 'admin' WHERE r.key = 'admin'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -455,16 +481,21 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'recipes.create', 'recipes.create',
'recipes.update', 'recipes.update',
'recipes.order', 'recipes.order',
'dish.create',
'dish.update',
'dish.order',
'life.posts.create', 'life.posts.create',
'life.posts.update', 'life.posts.update',
'life.posts.delete', 'life.posts.delete',
'life.comments.create', 'life.comments.create',
'life.comments.delete', 'life.comments.delete',
'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete' 'discussions.comments.delete',
'discussions.comments.like'
]) ])
WHERE r.key = 'editor' WHERE r.key = 'editor'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -499,6 +530,29 @@ JOIN permissions p ON p.key = ANY (ARRAY[
WHERE r.key = 'editor' WHERE r.key = 'editor'
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 = ANY (ARRAY[
'dish.create',
'dish.update',
'dish.delete',
'dish.order'
])
WHERE r.key = 'admin'
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 = ANY (ARRAY[
'dish.create',
'dish.update',
'dish.order'
])
WHERE r.key = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id) INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id SELECT r.id, p.id
FROM roles r FROM roles r
@@ -508,11 +562,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'life.posts.delete', 'life.posts.delete',
'life.comments.create', 'life.comments.create',
'life.comments.delete', 'life.comments.delete',
'life.comments.like',
'life.reactions.set', 'life.reactions.set',
'life.ratings.set', 'life.ratings.set',
'users.follow', 'users.follow',
'discussions.comments.create', 'discussions.comments.create',
'discussions.comments.delete' 'discussions.comments.delete',
'discussions.comments.like'
]) ])
WHERE r.key = 'member' WHERE r.key = 'member'
AND NOT EXISTS ( AND NOT EXISTS (
@@ -529,6 +585,20 @@ 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 = 'life.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = 'discussions.comments.like'
WHERE r.key IN ('admin', 'editor', 'member')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id) INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id SELECT r.id, p.id
FROM roles r FROM roles r
@@ -744,6 +814,19 @@ CREATE INDEX IF NOT EXISTS life_post_comments_parent_idx
CREATE INDEX IF NOT EXISTS life_post_comments_user_idx CREATE INDEX IF NOT EXISTS life_post_comments_user_idx
ON life_post_comments(created_by_user_id, created_at DESC, id DESC); ON life_post_comments(created_by_user_id, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS life_comment_likes (
comment_id integer NOT NULL REFERENCES life_post_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS life_comment_likes_comment_idx
ON life_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS life_comment_likes_user_idx
ON life_comment_likes(user_id, created_at DESC, comment_id DESC);
CREATE TABLE IF NOT EXISTS life_post_reactions ( CREATE TABLE IF NOT EXISTS life_post_reactions (
post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE, post_id integer NOT NULL REFERENCES life_posts(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -891,7 +974,6 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
CREATE TABLE IF NOT EXISTS items ( CREATE TABLE IF NOT EXISTS items (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
display_id integer NOT NULL CHECK (display_id > 0),
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
details text NOT NULL DEFAULT '', details text NOT NULL DEFAULT '',
category_key text NOT NULL DEFAULT 'other', category_key text NOT NULL DEFAULT 'other',
@@ -928,7 +1010,6 @@ CREATE TABLE IF NOT EXISTS items (
CREATE TABLE IF NOT EXISTS ancient_artifacts ( CREATE TABLE IF NOT EXISTS ancient_artifacts (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
display_id integer NOT NULL UNIQUE CHECK (display_id > 0),
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
details text NOT NULL DEFAULT '', details text NOT NULL DEFAULT '',
category_key text NOT NULL CHECK (category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')), category_key text NOT NULL CHECK (category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils')),
@@ -989,6 +1070,112 @@ CREATE TABLE IF NOT EXISTS recipe_materials (
PRIMARY KEY (recipe_id, item_id) PRIMARY KEY (recipe_id, item_id)
); );
CREATE TABLE IF NOT EXISTS dish_categories (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
cookware_item_id integer NOT NULL REFERENCES items(id),
main_material_item_id integer NOT NULL REFERENCES items(id),
total_material_quantity integer NOT NULL DEFAULT 2 CHECK (total_material_quantity >= 2),
effect text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE dish_categories
ADD COLUMN IF NOT EXISTS main_material_item_id integer REFERENCES items(id);
ALTER TABLE dish_categories
ADD COLUMN IF NOT EXISTS total_material_quantity integer NOT NULL DEFAULT 2;
DO $$
BEGIN
IF to_regclass('public.dishes') IS NOT NULL
AND EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'dishes'
AND column_name = 'main_material_item_id'
)
THEN
EXECUTE '
UPDATE dish_categories dc
SET main_material_item_id = source.main_material_item_id
FROM (
SELECT DISTINCT ON (category_id) category_id, main_material_item_id
FROM dishes
WHERE main_material_item_id IS NOT NULL
ORDER BY category_id, sort_order, id
) AS source
WHERE dc.id = source.category_id
AND dc.main_material_item_id IS NULL
';
END IF;
END $$;
UPDATE dish_categories
SET main_material_item_id = cookware_item_id
WHERE main_material_item_id IS NULL;
ALTER TABLE dish_categories
ALTER COLUMN main_material_item_id SET NOT NULL;
ALTER TABLE dish_categories
ALTER COLUMN total_material_quantity SET DEFAULT 2;
UPDATE dish_categories
SET total_material_quantity = 2
WHERE total_material_quantity < 2;
ALTER TABLE dish_categories
DROP CONSTRAINT IF EXISTS dish_categories_total_material_quantity_check;
ALTER TABLE dish_categories
ADD CONSTRAINT dish_categories_total_material_quantity_check CHECK (total_material_quantity >= 2);
CREATE TABLE IF NOT EXISTS dish_flavors (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS dishes (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
category_id integer NOT NULL REFERENCES dish_categories(id) ON DELETE CASCADE,
item_id integer NOT NULL UNIQUE REFERENCES items(id),
flavor_id integer NOT NULL REFERENCES dish_flavors(id),
secondary_material_1_item_id integer REFERENCES items(id),
secondary_material_2_item_id integer REFERENCES items(id),
pokemon_skill_id integer REFERENCES skills(id),
mosslax_effect text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (
secondary_material_1_item_id IS NULL
OR secondary_material_2_item_id IS NULL
OR secondary_material_1_item_id <> secondary_material_2_item_id
)
);
ALTER TABLE dishes
ADD COLUMN IF NOT EXISTS flavor_id integer REFERENCES dish_flavors(id);
ALTER TABLE dishes
ALTER COLUMN secondary_material_1_item_id DROP NOT NULL;
ALTER TABLE dishes
DROP COLUMN IF EXISTS main_material_item_id;
CREATE TABLE IF NOT EXISTS maps ( CREATE TABLE IF NOT EXISTS maps (
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,
@@ -1032,7 +1219,6 @@ ALTER TABLE life_tags
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false; ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
ALTER TABLE items ALTER TABLE items
ADD COLUMN IF NOT EXISTS display_id integer,
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS category_key text, ADD COLUMN IF NOT EXISTS category_key text,
ADD COLUMN IF NOT EXISTS usage_key text; ADD COLUMN IF NOT EXISTS usage_key text;
@@ -1053,10 +1239,6 @@ BEGIN
END IF; END IF;
END $$; END $$;
UPDATE items
SET display_id = id
WHERE display_id IS NULL;
UPDATE items i UPDATE items i
SET category_key = CASE lower(trim(c.name)) SET category_key = CASE lower(trim(c.name))
WHEN 'furniture' THEN 'furniture' WHEN 'furniture' THEN 'furniture'
@@ -1114,7 +1296,6 @@ WHERE usage_key IS NOT NULL
AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road'); AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road');
ALTER TABLE items ALTER TABLE items
ALTER COLUMN display_id SET NOT NULL,
ALTER COLUMN category_key SET NOT NULL, ALTER COLUMN category_key SET NOT NULL,
ALTER COLUMN category_key SET DEFAULT 'other'; ALTER COLUMN category_key SET DEFAULT 'other';
@@ -1124,7 +1305,6 @@ ALTER TABLE items
DROP CONSTRAINT IF EXISTS items_usage_key_check; DROP CONSTRAINT IF EXISTS items_usage_key_check;
ALTER TABLE items ALTER TABLE items
ADD CONSTRAINT items_display_id_positive CHECK (display_id > 0),
ADD CONSTRAINT items_category_key_check CHECK (category_key IN ( ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
'furniture', 'furniture',
'misc', 'misc',
@@ -1141,6 +1321,20 @@ ALTER TABLE items
)), )),
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road')); ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
DROP INDEX IF EXISTS items_display_event_item_key;
DROP INDEX IF EXISTS items_display_order_idx;
DROP INDEX IF EXISTS ancient_artifacts_display_order_idx;
ALTER TABLE ancient_artifacts
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_key,
DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_check;
ALTER TABLE items
DROP COLUMN IF EXISTS display_id;
ALTER TABLE ancient_artifacts
DROP COLUMN IF EXISTS display_id;
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id); CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id); CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id); CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
@@ -1153,11 +1347,12 @@ CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sor
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id); CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id); CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id); CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS items_display_event_item_key ON items(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id); CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id); CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_category_sort_order_idx ON dishes(category_id, sort_order, id);
CREATE INDEX IF NOT EXISTS dishes_sort_order_idx ON dishes(sort_order, id);
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id); CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id); CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
@@ -1235,6 +1430,19 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
ON entity_discussion_comments(created_by_user_id); ON entity_discussion_comments(created_by_user_id);
CREATE TABLE IF NOT EXISTS entity_discussion_comment_likes (
comment_id integer NOT NULL REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (comment_id, user_id)
);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_comment_idx
ON entity_discussion_comment_likes(comment_id);
CREATE INDEX IF NOT EXISTS entity_discussion_comment_likes_user_idx
ON entity_discussion_comment_likes(user_id, created_at DESC, comment_id DESC);
ALTER TABLE entity_discussion_comments ALTER TABLE entity_discussion_comments
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check; DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,8 @@ import {
createAncientArtifact, createAncientArtifact,
createConfig, createConfig,
createDailyChecklistItem, createDailyChecklistItem,
createDish,
createDishCategory,
createEntityDiscussionComment, createEntityDiscussionComment,
createEntityDiscussionReply, createEntityDiscussionReply,
createHabitat, createHabitat,
@@ -51,11 +53,15 @@ import {
deleteConfig, deleteConfig,
deleteAncientArtifact, deleteAncientArtifact,
deleteDailyChecklistItem, deleteDailyChecklistItem,
deleteDish,
deleteDishCategory,
deleteEntityDiscussionComment, deleteEntityDiscussionComment,
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
deleteLanguage, deleteLanguage,
deleteEntityDiscussionCommentLike,
deleteLifeComment, deleteLifeComment,
deleteLifeCommentLike,
deleteLifePost, deleteLifePost,
deleteLifePostRating, deleteLifePostRating,
deleteLifePostReaction, deleteLifePostReaction,
@@ -69,6 +75,7 @@ import {
getAncientArtifact, getAncientArtifact,
getHabitat, getHabitat,
getItem, getItem,
listDish,
getLifePost, getLifePost,
getOptions, getOptions,
getPokemon, getPokemon,
@@ -97,6 +104,8 @@ import {
reorderConfig, reorderConfig,
reorderAncientArtifacts, reorderAncientArtifacts,
reorderDailyChecklistItems, reorderDailyChecklistItems,
reorderDishCategories,
reorderDishes,
reorderHabitats, reorderHabitats,
reorderItems, reorderItems,
reorderLanguages, reorderLanguages,
@@ -108,9 +117,13 @@ import {
restoreLifeComment, restoreLifeComment,
setLifePostRating, setLifePostRating,
setLifePostReaction, setLifePostReaction,
setEntityDiscussionCommentLike,
setLifeCommentLike,
updateConfig, updateConfig,
updateAncientArtifact, updateAncientArtifact,
updateDailyChecklistItem, updateDailyChecklistItem,
updateDish,
updateDishCategory,
updateHabitat, updateHabitat,
updateItem, updateItem,
updateLanguage, updateLanguage,
@@ -1470,6 +1483,26 @@ app.post('/api/life-comments/:id/restore', async (request, reply) => {
return comment ? comment : notFound(reply, request); return comment ? comment : notFound(reply, request);
}); });
app.put('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setLifeCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/life-comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'life.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteLifeCommentLike(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,
@@ -1580,6 +1613,28 @@ app.post('/api/discussions/comments/:id/moderation/retry', async (request, reply
return comment ? comment : notFound(reply, request); return comment ? comment : notFound(reply, request);
}); });
app.put('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await setEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.delete('/api/discussions/comments/:id/like', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'discussions.comments.like', 'communityReaction');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const comment = await deleteEntityDiscussionCommentLike(Number(id), user.id);
return comment ? comment : notFound(reply, request);
});
app.get('/api/pokemon', async (request) => app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request)) listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
); );
@@ -1865,6 +1920,72 @@ app.delete('/api/recipes/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request); return deleted ? reply.code(204).send() : notFound(reply, request);
}); });
app.get('/api/dish', async (request) => listDish(requestLocale(request)));
app.post('/api/admin/dish/categories', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite');
return user
? reply.code(201).send(await createDishCategory(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/admin/dish/categories/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite');
return user ? reorderDishCategories(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/dish/categories/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const category = await updateDishCategory(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return category ? category : notFound(reply, request);
});
app.delete('/api/admin/dish/categories/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteDishCategory(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/admin/dish/dishes', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite');
return user
? reply.code(201).send(await createDish(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
});
app.put('/api/admin/dish/dishes/order', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite');
return user ? reorderDishes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
});
app.put('/api/admin/dish/dishes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const dish = await updateDish(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return dish ? dish : notFound(reply, request);
});
app.delete('/api/admin/dish/dishes/:id', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite');
if (!user) {
return;
}
const { id } = request.params as { id: string };
const deleted = await deleteDish(Number(id), user.id);
return deleted ? reply.code(204).send() : notFound(reply, request);
});
app.post('/api/admin/daily-checklist', async (request, reply) => { app.post('/api/admin/daily-checklist', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite');
return user return user

View File

@@ -97,7 +97,7 @@ const navItems = computed<NavItem[]>(() => {
}, },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() }, { label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() }, { label: t('nav.dish'), to: '/dish', icon: iconDish },
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() }, { label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() }, { label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() }, { label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },

View File

@@ -16,7 +16,6 @@ const changeLabelKeys: Record<string, string> = {
标题: 'pages.checklist.task', 标题: 'pages.checklist.task',
'Pokemon ID': 'pages.pokemon.id', 'Pokemon ID': 'pages.pokemon.id',
'Pokopia ID': 'pages.pokemon.id', 'Pokopia ID': 'pages.pokemon.id',
'Display ID': 'pages.items.displayId',
'Event item': 'common.eventItem', 'Event item': 'common.eventItem',
'Event Pokemon': 'pages.pokemon.eventItem', 'Event Pokemon': 'pages.pokemon.eventItem',
'Event Habitat': 'pages.habitats.eventItem', 'Event Habitat': 'pages.habitats.eventItem',
@@ -118,12 +117,17 @@ function changeValue(value: string): string {
return values[value] ?? value; return values[value] ?? value;
} }
function visibleChanges(entry: EditHistoryEntry) {
return entry.changes.filter((change) => change.label !== 'Display ID');
}
function historySummary(entry: EditHistoryEntry): string { function historySummary(entry: EditHistoryEntry): string {
if (!entry.changes.length) { const changes = visibleChanges(entry);
if (!changes.length) {
return actionLabel(entry.action); return actionLabel(entry.action);
} }
return entry.changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', '); return changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
} }
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
@@ -175,8 +179,8 @@ function formatDateTime(value: string): string {
</summary> </summary>
<div class="edit-history-entry__content"> <div class="edit-history-entry__content">
<dl v-if="entry.changes.length" class="edit-change-list"> <dl v-if="visibleChanges(entry).length" class="edit-change-list">
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`"> <div v-for="change in visibleChanges(entry)" :key="`${change.label}-${change.before}-${change.after}`">
<dt>{{ changeLabel(change.label) }}</dt> <dt>{{ changeLabel(change.label) }}</dt>
<dd> <dd>
<span class="edit-change-list__label">{{ t('history.before') }}</span> <span class="edit-change-list__label">{{ t('history.before') }}</span>

View File

@@ -11,18 +11,28 @@ defineProps<{
marker?: string; marker?: string;
image?: { src: string; alt: string }; image?: { src: string; alt: string };
ribbon?: string; ribbon?: string;
compactTooltip?: boolean;
}>(); }>();
</script> </script>
<template> <template>
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to"> <RouterLink
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span> v-if="to"
class="entity-card entity-card--link"
:class="{ 'entity-card--collection-compact': compactTooltip }"
:to="to"
:aria-label="compactTooltip ? title : undefined"
>
<span v-if="ribbon" class="entity-card__ribbon-clip" aria-hidden="true">
<span class="entity-card__ribbon">{{ ribbon }}</span>
</span>
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }"> <span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" /> <img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" /> <Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
<PokeBallMark v-else-if="!marker" size="30px" /> <PokeBallMark v-else-if="!marker" size="30px" />
<span v-else>{{ marker }}</span> <span v-else>{{ marker }}</span>
</span> </span>
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
<div class="entity-card__content"> <div class="entity-card__content">
<span class="entity-card__title">{{ title }}</span> <span class="entity-card__title">{{ title }}</span>
<slot name="after-title"></slot> <slot name="after-title"></slot>
@@ -31,14 +41,17 @@ defineProps<{
</div> </div>
</RouterLink> </RouterLink>
<article v-else class="entity-card"> <article v-else class="entity-card" :class="{ 'entity-card--collection-compact': compactTooltip }">
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span> <span v-if="ribbon" class="entity-card__ribbon-clip" aria-hidden="true">
<span class="entity-card__ribbon">{{ ribbon }}</span>
</span>
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }"> <span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" /> <img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" /> <Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
<PokeBallMark v-else-if="!marker" size="30px" /> <PokeBallMark v-else-if="!marker" size="30px" />
<span v-else>{{ marker }}</span> <span v-else>{{ marker }}</span>
</span> </span>
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
<div class="entity-card__content"> <div class="entity-card__content">
<span class="entity-card__title">{{ title }}</span> <span class="entity-card__title">{{ title }}</span>
<slot name="after-title"></slot> <slot name="after-title"></slot>

View File

@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import StatusBadge from './StatusBadge.vue'; import StatusBadge from './StatusBadge.vue';
import Tabs, { type TabOption } from './Tabs.vue'; import Tabs, { type TabOption } from './Tabs.vue';
import { iconCancel, iconComment, iconDelete, iconReply, iconWarning } from '../icons'; import { iconCancel, iconComment, iconDelete, iconReactionLike, iconReply, iconWarning } from '../icons';
import { import {
api, api,
getAuthToken, getAuthToken,
@@ -13,6 +13,7 @@ import {
setAuthToken, setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort,
type DiscussionEntityType, type DiscussionEntityType,
type EntityDiscussionComment, type EntityDiscussionComment,
type Language, type Language,
@@ -41,7 +42,9 @@ const formError = ref('');
const commentErrors = ref<Record<string, string>>({}); const commentErrors = ref<Record<string, string>>({});
const commentInput = ref<HTMLTextAreaElement | null>(null); const commentInput = ref<HTMLTextAreaElement | null>(null);
const activeLanguageCode = ref('all'); const activeLanguageCode = ref('all');
const activeSort = ref<CommentSort>('oldest');
const moderationBusyId = ref<number | null>(null); const moderationBusyId = ref<number | null>(null);
const likeBusyId = ref<number | null>(null);
const commentMaxLength = 1000; const commentMaxLength = 1000;
const discussionPageSize = 20; const discussionPageSize = 20;
const allLanguageValue = 'all'; const allLanguageValue = 'all';
@@ -56,12 +59,19 @@ function can(permissionKey: string) {
} }
const canComment = computed(() => can('discussions.comments.create')); const canComment = computed(() => can('discussions.comments.create'));
const canLikeComments = computed(() => can('discussions.comments.like'));
const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length)); const charactersLeft = computed(() => Math.max(0, commentMaxLength - body.value.length));
const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value)); const selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
const languageTabs = computed<TabOption[]>(() => [ const languageTabs = computed<TabOption[]>(() => [
{ value: allLanguageValue, label: t('discussion.allLanguages') }, { value: allLanguageValue, label: t('discussion.allLanguages') },
...languages.value.map((language) => ({ value: language.code, label: language.name })) ...languages.value.map((language) => ({ value: language.code, label: language.name }))
]); ]);
const sortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('discussion.sortOldest') },
{ value: 'latest', label: t('discussion.sortLatest') },
{ value: 'most-liked', label: t('discussion.sortMostLiked') },
{ value: 'most-replied', label: t('discussion.sortMostReplied') }
]);
async function loadCurrentUser() { async function loadCurrentUser() {
authReady.value = false; authReady.value = false;
@@ -119,7 +129,8 @@ async function loadDiscussion(reset = true) {
const page = await api.entityDiscussion(props.entityType, props.entityId, { const page = await api.entityDiscussion(props.entityType, props.entityId, {
limit: discussionPageSize, limit: discussionPageSize,
cursor: reset ? null : nextCursor.value, cursor: reset ? null : nextCursor.value,
language: selectedLanguageCode.value language: selectedLanguageCode.value,
sort: activeSort.value
}); });
if (nextRequestId === requestId) { if (nextRequestId === requestId) {
comments.value = reset ? page.items : mergeComments(comments.value, page.items); comments.value = reset ? page.items : mergeComments(comments.value, page.items);
@@ -151,6 +162,17 @@ function commentKey(commentId: number) {
return `comment-${commentId}`; return `comment-${commentId}`;
} }
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function handleSortChange(event: Event) {
if (event.target instanceof HTMLSelectElement) {
activeSort.value = event.target.value as CommentSort;
void loadDiscussion();
}
}
function replyBody(commentId: number) { function replyBody(commentId: number) {
return replyBodies.value[commentId] ?? ''; return replyBodies.value[commentId] ?? '';
} }
@@ -181,6 +203,14 @@ function canRetryModeration(comment: EntityDiscussionComment) {
return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment); return !comment.deleted && comment.moderationStatus !== 'approved' && comment.moderationStatus !== 'reviewing' && canSeeModeration(comment);
} }
function canLikeComment(comment: EntityDiscussionComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function commentLikeLabel(comment: EntityDiscussionComment) {
return comment.myLiked ? t('discussion.unlikeComment') : t('discussion.likeComment');
}
function moderationReasonVisible(comment: EntityDiscussionComment) { function moderationReasonVisible(comment: EntityDiscussionComment) {
return ( return (
!comment.deleted && !comment.deleted &&
@@ -267,6 +297,9 @@ async function submitComment() {
comments.value = [...comments.value, comment]; comments.value = [...comments.value, comment];
commentTotal.value += 1; commentTotal.value += 1;
body.value = ''; body.value = '';
if (activeSort.value !== 'oldest') {
void loadDiscussion();
}
} catch (error) { } catch (error) {
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed'); formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
} finally { } finally {
@@ -291,8 +324,12 @@ async function submitReply(comment: EntityDiscussionComment) {
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
}); });
comment.replies.push(reply); comment.replies.push(reply);
comment.replyCount += 1;
commentTotal.value += 1; commentTotal.value += 1;
cancelReply(comment.id); cancelReply(comment.id);
if (activeSort.value === 'most-replied') {
void loadDiscussion();
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
} finally { } finally {
@@ -317,6 +354,49 @@ async function retryModeration(comment: EntityDiscussionComment) {
} }
} }
function replaceCommentInTree(items: EntityDiscussionComment[], updated: EntityDiscussionComment): boolean {
for (let index = 0; index < items.length; index += 1) {
const comment = items[index];
if (!comment) {
continue;
}
if (comment.id === updated.id) {
items[index] = { ...updated, replies: comment.replies };
return true;
}
if (replaceCommentInTree(comment.replies, updated)) {
return true;
}
}
return false;
}
async function toggleCommentLike(comment: EntityDiscussionComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
likeBusyId.value = comment.id;
clearCommentError(key);
try {
const updated = comment.myLiked
? await api.deleteEntityDiscussionCommentLike(comment.id)
: await api.setEntityDiscussionCommentLike(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
if (activeSort.value === 'most-liked') {
void loadDiscussion();
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.commentLikeFailed'));
} finally {
likeBusyId.value = null;
}
}
function updateDiscussionCommentModeration( function updateDiscussionCommentModeration(
items: EntityDiscussionComment[], items: EntityDiscussionComment[],
commentId: number, commentId: number,
@@ -455,6 +535,14 @@ onUnmounted(() => {
</div> </div>
<Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" /> <Tabs id="entity-discussion-language" v-model="activeLanguageCode" :tabs="languageTabs" :label="t('discussion.languages')" />
<label class="entity-discussion-sort">
<span>{{ t('discussion.sort') }}</span>
<select :value="activeSort" @change="handleSortChange">
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
<div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true"> <div v-if="!authReady" class="entity-discussion-skeleton" aria-hidden="true">
<Skeleton variant="box" height="112px" /> <Skeleton variant="box" height="112px" />
@@ -528,6 +616,18 @@ onUnmounted(() => {
</p> </p>
<div v-if="!comment.deleted" class="entity-discussion-comment__actions"> <div v-if="!comment.deleted" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || likeBusyId === comment.id"
@click="toggleCommentLike(comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button <button
v-if="canComment" v-if="canComment"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -563,6 +663,9 @@ onUnmounted(() => {
</button> </button>
</div> </div>
<p v-if="commentErrors[likeKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<p v-if="commentErrors[commentKey(comment.id)]" class="entity-discussion-form__error" role="alert"> <p v-if="commentErrors[commentKey(comment.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(comment.id)] }} {{ commentErrors[commentKey(comment.id)] }}
</p> </p>
@@ -624,7 +727,19 @@ onUnmounted(() => {
<strong>{{ t('discussion.moderationReason') }}</strong> <strong>{{ t('discussion.moderationReason') }}</strong>
<span>{{ reply.moderationReason }}</span> <span>{{ reply.moderationReason }}</span>
</p> </p>
<div v-if="canManageComment(reply) || canRetryModeration(reply)" class="entity-discussion-comment__actions"> <div v-if="!reply.deleted" class="entity-discussion-comment__actions">
<button
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || likeBusyId === reply.id"
@click="toggleCommentLike(reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button <button
v-if="canRetryModeration(reply)" v-if="canRetryModeration(reply)"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -639,6 +754,7 @@ onUnmounted(() => {
</span> </span>
</button> </button>
<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('discussion.deleteComment')" :aria-label="t('discussion.deleteComment')"
@@ -648,6 +764,9 @@ onUnmounted(() => {
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
</button> </button>
</div> </div>
<p v-if="commentErrors[likeKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert"> <p v-if="commentErrors[commentKey(reply.id)]" class="entity-discussion-form__error" role="alert">
{{ commentErrors[commentKey(reply.id)] }} {{ commentErrors[commentKey(reply.id)] }}
</p> </p>

View File

@@ -13,6 +13,7 @@ import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifePostDetail from '../views/LifePostDetail.vue'; import LifePostDetail from '../views/LifePostDetail.vue';
import LifeView from '../views/LifeView.vue'; import LifeView from '../views/LifeView.vue';
import DishView from '../views/DishView.vue';
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue'; import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
import LegalView from '../views/LegalView.vue'; import LegalView from '../views/LegalView.vue';
import ComingSoonView from '../views/ComingSoonView.vue'; import ComingSoonView from '../views/ComingSoonView.vue';
@@ -267,9 +268,8 @@ export const router = createRouter({
{ {
path: '/dish', path: '/dish',
name: 'dish', name: 'dish',
component: ComingSoonView, component: DishView,
props: { page: 'dish' }, meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dish.title', descriptionKey: 'pages.comingSoon.sections.dish.subtitle', noindex: true }) }
}, },
{ {
path: '/events', path: '/events',

View File

@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token'; const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change'; const authChangeEvent = 'pokopia-auth-change';
export type TranslationField = 'name' | 'title' | 'details' | 'genus'; export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>; export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
export interface Language { export interface Language {
@@ -246,7 +246,6 @@ export interface HabitatUsage {
} }
export interface RecipeResultItem extends NamedEntity { export interface RecipeResultItem extends NamedEntity {
displayId: number;
image?: EntityImage | null; image?: EntityImage | null;
category?: NamedEntity; category?: NamedEntity;
usage?: NamedEntity | null; usage?: NamedEntity | null;
@@ -254,7 +253,6 @@ export interface RecipeResultItem extends NamedEntity {
export interface Item extends EditInfo { export interface Item extends EditInfo {
id: number; id: number;
displayId: number;
name: string; name: string;
baseName?: string; baseName?: string;
details: string; details: string;
@@ -276,7 +274,6 @@ export interface Item extends EditInfo {
export interface AncientArtifact extends EditInfo { export interface AncientArtifact extends EditInfo {
id: number; id: number;
displayId: number;
name: string; name: string;
baseName?: string; baseName?: string;
details: string; details: string;
@@ -311,6 +308,36 @@ export interface Recipe extends EditInfo {
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>; materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
} }
export interface ItemLink extends NamedEntity {
image?: EntityImage | null;
category?: NamedEntity;
}
export interface Dish extends EditInfo {
id: number;
flavor: NamedEntity;
mosslaxEffect: string;
baseMosslaxEffect?: string;
translations?: TranslationMap;
category: NamedEntity;
item: ItemLink;
secondaryMaterials: ItemLink[];
pokemonSkill: Skill | null;
}
export interface DishCategory extends EditInfo {
id: number;
name: string;
baseName?: string;
effect: string;
baseEffect?: string;
translations?: TranslationMap;
cookware: ItemLink;
mainMaterial: ItemLink;
totalMaterialQuantity: number;
dishes: Dish[];
}
export interface DailyChecklistItem { export interface DailyChecklistItem {
id: number; id: number;
title: string; title: string;
@@ -421,8 +448,11 @@ export interface CommentPageParams {
cursor?: string | null; cursor?: string | null;
limit?: number; limit?: number;
language?: string; language?: string;
sort?: CommentSort;
} }
export type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
export interface LifeComment { export interface LifeComment {
id: number; id: number;
postId: number; postId: number;
@@ -435,6 +465,9 @@ export interface LifeComment {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
author: UserSummary | null; author: UserSummary | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
replies: LifeComment[]; replies: LifeComment[];
} }
@@ -546,6 +579,7 @@ export interface Options {
maps: NamedEntity[]; maps: NamedEntity[];
lifeCategories: LifeCategory[]; lifeCategories: LifeCategory[];
gameVersions: GameVersion[]; gameVersions: GameVersion[];
dishFlavors: NamedEntity[];
} }
export interface AuthUser { export interface AuthUser {
@@ -705,7 +739,8 @@ export type ConfigType =
| 'acquisition-methods' | 'acquisition-methods'
| 'maps' | 'maps'
| 'life-tags' | 'life-tags'
| 'game-versions'; | 'game-versions'
| 'dish-flavors';
export interface PokemonPayload { export interface PokemonPayload {
dataId?: number | null; dataId?: number | null;
@@ -752,7 +787,6 @@ export interface PokemonImageOptionsResult {
} }
export interface ItemPayload { export interface ItemPayload {
displayId: number;
name: string; name: string;
details: string; details: string;
translations?: TranslationMap; translations?: TranslationMap;
@@ -769,7 +803,6 @@ export interface ItemPayload {
} }
export interface AncientArtifactPayload { export interface AncientArtifactPayload {
displayId: number;
name: string; name: string;
details: string; details: string;
translations?: TranslationMap; translations?: TranslationMap;
@@ -784,6 +817,25 @@ export interface RecipePayload {
materials: Array<{ itemId: number; quantity: number }>; materials: Array<{ itemId: number; quantity: number }>;
} }
export interface DishCategoryPayload {
name: string;
effect: string;
translations?: TranslationMap;
cookwareItemId: number;
mainMaterialItemId: number;
totalMaterialQuantity: number;
}
export interface DishPayload {
categoryId: number;
itemId: number;
flavorId: number;
secondaryMaterialItemIds: number[];
pokemonSkillId: number | null;
mosslaxEffect: string;
translations?: TranslationMap;
}
export interface HabitatPayload { export interface HabitatPayload {
name: string; name: string;
translations?: TranslationMap; translations?: TranslationMap;
@@ -831,6 +883,9 @@ export interface EntityDiscussionComment {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
author: UserSummary | null; author: UserSummary | null;
likeCount: number;
replyCount: number;
myLiked: boolean;
replies: EntityDiscussionComment[]; replies: EntityDiscussionComment[];
} }
@@ -1229,7 +1284,8 @@ export const api = {
`/api/life-posts/${postId}/comments${buildQuery({ `/api/life-posts/${postId}/comments${buildQuery({
cursor: params.cursor ?? undefined, cursor: params.cursor ?? undefined,
limit: params.limit, limit: params.limit,
language: params.language language: params.language,
sort: params.sort
})}` })}`
), ),
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) => createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
@@ -1237,13 +1293,16 @@ export const api = {
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', {}), restoreLifeComment: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/restore`, 'POST', {}),
setLifeCommentLike: (id: string | number) => sendJson<LifeComment>(`/api/life-comments/${id}/like`, 'PUT', {}),
deleteLifeCommentLike: (id: string | number) => deleteAndGetJson<LifeComment>(`/api/life-comments/${id}/like`),
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>(
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({ `/api/discussions/${entityType}/${entityId}/comments${buildQuery({
cursor: params.cursor ?? undefined, cursor: params.cursor ?? undefined,
limit: params.limit, limit: params.limit,
language: params.language language: params.language,
sort: params.sort
})}` })}`
), ),
createEntityDiscussionComment: ( createEntityDiscussionComment: (
@@ -1259,6 +1318,10 @@ export const api = {
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload), ) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
retryEntityDiscussionModeration: (id: string | number) => retryEntityDiscussionModeration: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}), sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/moderation/retry`, 'POST', {}),
setEntityDiscussionCommentLike: (id: string | number) =>
sendJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/like`, 'PUT', {}),
deleteEntityDiscussionCommentLike: (id: string | number) =>
deleteAndGetJson<EntityDiscussionComment>(`/api/discussions/comments/${id}/like`),
deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`), deleteEntityDiscussionComment: (id: string | number) => deleteJson(`/api/discussions/comments/${id}`),
uploadImage: ( uploadImage: (
entityType: ImageUploadEntityType, entityType: ImageUploadEntityType,
@@ -1342,5 +1405,16 @@ export const api = {
updateRecipe: (id: string | number, payload: RecipePayload) => updateRecipe: (id: string | number, payload: RecipePayload) =>
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload), sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`), deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`),
reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids }) reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids }),
dish: () => getJson<DishCategory[]>('/api/dish'),
createDishCategory: (payload: DishCategoryPayload) => sendJson<DishCategory>('/api/admin/dish/categories', 'POST', payload),
updateDishCategory: (id: string | number, payload: DishCategoryPayload) =>
sendJson<DishCategory>(`/api/admin/dish/categories/${id}`, 'PUT', payload),
deleteDishCategory: (id: string | number) => deleteJson(`/api/admin/dish/categories/${id}`),
reorderDishCategories: (ids: number[]) => sendJson<DishCategory[]>('/api/admin/dish/categories/order', 'PUT', { ids }),
createDish: (payload: DishPayload) => sendJson<Dish>('/api/admin/dish/dishes', 'POST', payload),
updateDish: (id: string | number, payload: DishPayload) =>
sendJson<Dish>(`/api/admin/dish/dishes/${id}`, 'PUT', payload),
deleteDish: (id: string | number) => deleteJson(`/api/admin/dish/dishes/${id}`),
reorderDishes: (ids: number[]) => sendJson<DishCategory[]>('/api/admin/dish/dishes/order', 'PUT', { ids })
}; };

View File

@@ -2343,9 +2343,17 @@ button:disabled,
object-fit: contain; object-fit: contain;
} }
.entity-card__ribbon { .entity-card__ribbon-clip {
position: absolute; position: absolute;
z-index: 1; z-index: 1;
inset: 0;
overflow: hidden;
border-radius: calc(var(--radius-card) - 2px);
pointer-events: none;
}
.entity-card__ribbon {
position: absolute;
top: 14px; top: 14px;
left: -38px; left: -38px;
width: 132px; width: 132px;
@@ -2362,7 +2370,6 @@ button:disabled,
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 950; font-weight: 950;
line-height: 1; line-height: 1;
pointer-events: none;
text-align: center; text-align: center;
} }
@@ -2438,6 +2445,86 @@ button:disabled,
font-weight: 850; font-weight: 850;
} }
.collections-card-grid {
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px;
}
.collections-card-grid .entity-card--collection-compact {
min-height: 0;
aspect-ratio: 1;
align-content: center;
justify-content: center;
gap: 0;
padding: 10px;
overflow: visible;
}
.collections-card-grid .entity-card--collection-compact:hover,
.collections-card-grid .entity-card--collection-compact:focus-visible {
z-index: 4;
}
.collections-card-grid .entity-card--collection-compact .entity-card__mark {
width: min(100%, 72px);
height: auto;
aspect-ratio: 1;
}
.collections-card-grid .entity-card--collection-compact .skeleton-entity-mark {
width: min(100%, 72px) !important;
height: auto !important;
aspect-ratio: 1;
}
.collections-card-grid .entity-card--collection-compact .entity-card__content {
display: none;
}
.entity-card__tooltip {
position: absolute;
z-index: 5;
bottom: calc(100% + 8px);
left: 50%;
width: max-content;
max-width: 180px;
padding: 6px 8px;
transform: translate(-50%, 4px);
border: 2px solid var(--line-strong);
border-radius: var(--radius-small);
background: var(--surface-raised);
color: var(--ink);
box-shadow: 0 3px 0 var(--line-strong);
font-size: 0.82rem;
font-weight: 850;
line-height: 1.25;
opacity: 0;
pointer-events: none;
text-align: center;
transition:
opacity 0.14s ease,
transform 0.14s ease;
}
.entity-card__tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
width: 10px;
height: 10px;
transform: translate(-50%, -4px) rotate(45deg);
border-right: 2px solid var(--line-strong);
border-bottom: 2px solid var(--line-strong);
background: var(--surface-raised);
}
.entity-card--collection-compact:hover .entity-card__tooltip,
.entity-card--collection-compact:focus-visible .entity-card__tooltip {
transform: translate(-50%, 0);
opacity: 1;
}
.catalog-card-action { .catalog-card-action {
min-height: 36px; min-height: 36px;
max-width: 100%; max-width: 100%;
@@ -3262,7 +3349,8 @@ button:disabled,
font-weight: 950; font-weight: 950;
} }
.life-comments__header span { .life-comments__header > span,
.life-comments__header > div > span {
min-width: 32px; min-width: 32px;
padding: 2px 8px; padding: 2px 8px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -3274,6 +3362,25 @@ button:disabled,
text-align: center; text-align: center;
} }
.life-comments__sort {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
.life-comments__sort select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink);
font-size: 13px;
font-weight: 800;
}
.life-comment-form { .life-comment-form {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -3386,6 +3493,13 @@ button:disabled,
gap: 8px; gap: 8px;
} }
.life-comment__action-count {
min-width: 1ch;
font-size: 12px;
font-weight: 900;
line-height: 1;
}
.life-comments__empty { .life-comments__empty {
margin: 0; margin: 0;
} }
@@ -4529,6 +4643,26 @@ button:disabled,
font-weight: 800; font-weight: 800;
} }
.entity-discussion-sort {
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
color: var(--muted);
font-size: 13px;
font-weight: 800;
}
.entity-discussion-sort select {
min-height: 34px;
border: 1px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink);
font-size: 13px;
font-weight: 800;
}
.entity-discussion-skeleton, .entity-discussion-skeleton,
.entity-discussion-form, .entity-discussion-form,
.entity-discussion-list { .entity-discussion-list {
@@ -7712,6 +7846,31 @@ button:disabled,
text-align: left; text-align: left;
} }
.collections-card-grid {
gap: 10px;
}
.collections-card-grid .entity-card--collection-compact {
aspect-ratio: auto;
justify-content: stretch;
gap: 10px;
padding: 12px;
overflow: hidden;
}
.collections-card-grid .entity-card--collection-compact .entity-card__content {
display: grid;
}
.collections-card-grid .entity-card--collection-compact .skeleton-entity-mark {
width: 56px !important;
height: 56px !important;
}
.collections-card-grid .entity-card--collection-compact .entity-card__tooltip {
display: none;
}
.pokemon-list-grid .entity-card__mark, .pokemon-list-grid .entity-card__mark,
.catalog-card-grid .entity-card__mark { .catalog-card-grid .entity-card__mark {
width: 56px; width: 56px;
@@ -8642,6 +8801,11 @@ button:disabled,
padding: 10px; padding: 10px;
} }
.collections-card-grid .entity-card--collection-compact {
gap: 8px;
padding: 10px;
}
.entity-card__mark { .entity-card__mark {
width: 34px; width: 34px;
height: 34px; height: 34px;
@@ -8653,6 +8817,16 @@ button:disabled,
height: 48px; height: 48px;
} }
.collections-card-grid .entity-card--collection-compact .entity-card__mark {
width: 48px;
height: 48px;
}
.collections-card-grid .entity-card--collection-compact .skeleton-entity-mark {
width: 48px !important;
height: 48px !important;
}
.pokemon-list-grid .pokeball-mark, .pokemon-list-grid .pokeball-mark,
.catalog-card-grid .pokeball-mark { .catalog-card-grid .pokeball-mark {
--ball-size: 36px !important; --ball-size: 36px !important;
@@ -8777,6 +8951,156 @@ button:disabled,
} }
} }
.dish-category-panel {
display: grid;
gap: 24px;
}
.dish-category-summary {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.dish-category-summary__content {
display: grid;
gap: 14px;
}
.dish-category-summary__content h2 {
margin: 0;
font-size: 24px;
}
.dish-media-link {
width: 112px;
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
box-shadow: var(--shadow-soft);
}
.dish-media-link img {
width: 82%;
height: 82%;
object-fit: contain;
}
.dish-media-link--small {
width: 76px;
box-shadow: none;
}
.dish-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.dish-card {
min-width: 0;
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 14px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
}
.dish-card__content {
min-width: 0;
display: grid;
gap: 10px;
}
.dish-card__title {
color: var(--ink);
font-weight: 900;
line-height: 1.3;
}
.dish-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dish-card__meta span {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 4px 8px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
color: var(--ink-soft);
font-size: 13px;
font-weight: 800;
}
.dish-category-effect-row {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.dish-category-effect-row strong {
color: var(--ink-soft);
font-size: 13px;
}
.dish-form-stack {
display: grid;
gap: 14px;
}
.dish-form-row {
display: grid;
gap: 14px;
}
.dish-form-row--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dish-form-row--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.info-list--compact {
gap: 8px;
font-size: 14px;
}
@media (max-width: 640px) {
.dish-category-summary,
.dish-card {
grid-template-columns: 1fr;
}
.dish-form-row,
.dish-form-row--3,
.dish-form-row--4 {
grid-template-columns: 1fr;
}
.dish-media-link {
width: 96px;
}
.dish-media-link--small {
width: 72px;
}
}
@media (max-width: 360px) { @media (max-width: 360px) {
.brand-lockup--topbar > span { .brand-lockup--topbar > span {
display: none; display: none;

View File

@@ -16,6 +16,7 @@ import {
iconCancel, iconCancel,
iconChecklist, iconChecklist,
iconDelete, iconDelete,
iconDish,
iconEdit, iconEdit,
iconHabitat, iconHabitat,
iconItem, iconItem,
@@ -43,6 +44,8 @@ import {
type DataToolsBundle, type DataToolsBundle,
type DataToolsSummary, type DataToolsSummary,
type DailyChecklistItem, type DailyChecklistItem,
type Dish,
type DishCategory,
type GameVersion, type GameVersion,
type Habitat, type Habitat,
type Item, type Item,
@@ -80,6 +83,7 @@ type AdminTab =
| 'items' | 'items'
| 'ancientArtifacts' | 'ancientArtifacts'
| 'recipes' | 'recipes'
| 'dish'
| 'habitats'; | 'habitats';
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
@@ -131,6 +135,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
items: iconItem, items: iconItem,
ancientArtifacts: iconArtifact, ancientArtifacts: iconArtifact,
recipes: iconRecipe, recipes: iconRecipe,
dish: iconDish,
habitats: iconHabitat habitats: iconHabitat
}; };
@@ -156,6 +161,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
permission: ['ancient-artifacts.order', 'ancient-artifacts.delete'] permission: ['ancient-artifacts.order', 'ancient-artifacts.delete']
}, },
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] }, { key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] },
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }, { key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] } { key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
] ]
@@ -197,7 +203,8 @@ const configTypes = computed<
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') }, { key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true }, { key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true } { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
{ key: 'dish-flavors', label: t('config.dishFlavors') }
]); ]);
const activeTab = ref<AdminTab>('config'); const activeTab = ref<AdminTab>('config');
@@ -212,6 +219,10 @@ const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]); const itemRows = ref<Item[]>([]);
const ancientArtifactRows = ref<AncientArtifact[]>([]); const ancientArtifactRows = ref<AncientArtifact[]>([]);
const recipeRows = ref<Recipe[]>([]); const recipeRows = ref<Recipe[]>([]);
const dishCategoryRows = ref<DishCategory[]>([]);
const dishItemRows = ref<Item[]>([]);
const dishSkillRows = ref<Skill[]>([]);
const dishFlavorRows = ref<NamedEntity[]>([]);
const habitatRows = ref<Habitat[]>([]); const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]); const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null); const aiModerationSettings = ref<AiModerationSettings | null>(null);
@@ -231,6 +242,25 @@ const configForm = ref({
changeLog: '' changeLog: ''
}); });
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap }); const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
const dishCategoryForm = ref({
id: 0,
name: '',
effect: '',
translations: {} as TranslationMap,
cookwareItemId: '',
mainMaterialItemId: '',
totalMaterialQuantity: 2
});
const dishForm = ref({
id: 0,
categoryId: '',
itemId: '',
flavorId: '',
translations: {} as TranslationMap,
secondaryMaterialItemIds: ['', ''],
pokemonSkillId: '',
mosslaxEffect: ''
});
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }); const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] }); const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
const aiModerationForm = ref({ const aiModerationForm = ref({
@@ -262,6 +292,8 @@ const permissionForm = ref({ id: 0, key: '', name: '', description: '', category
const editingLanguageCode = ref(''); const editingLanguageCode = ref('');
const configModalOpen = ref(false); const configModalOpen = ref(false);
const checklistModalOpen = ref(false); const checklistModalOpen = ref(false);
const dishCategoryModalOpen = ref(false);
const dishModalOpen = ref(false);
const languageModalOpen = ref(false); const languageModalOpen = ref(false);
const wordingModalOpen = ref(false); const wordingModalOpen = ref(false);
const userRoleModalOpen = ref(false); const userRoleModalOpen = ref(false);
@@ -320,6 +352,13 @@ const configModalTitle = computed(() =>
configForm.value.id ? t('pages.admin.editConfig', { name: selectedConfig.value.label }) : t('pages.admin.newConfig', { name: selectedConfig.value.label }) configForm.value.id ? t('pages.admin.editConfig', { name: selectedConfig.value.label }) : t('pages.admin.newConfig', { name: selectedConfig.value.label })
); );
const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask'))); const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask')));
const dishCategoryModalTitle = computed(() =>
dishCategoryForm.value.id ? t('pages.dish.editCategory') : t('pages.dish.newCategory')
);
const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish')));
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage'))); const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
const wordingModalTitle = computed(() => t('pages.admin.editWording')); const wordingModalTitle = computed(() => t('pages.admin.editWording'));
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole'))); const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole')));
@@ -409,11 +448,15 @@ const configLabel = (item: EditableConfig) => item.name;
const pokemonKey = (item: Pokemon) => item.id; const pokemonKey = (item: Pokemon) => item.id;
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`; const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
const itemKey = (item: Item) => item.id; const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => `#${item.displayId} ${item.name}`; const itemLabel = (item: Item) => item.name;
const ancientArtifactKey = (item: AncientArtifact) => item.id; const ancientArtifactKey = (item: AncientArtifact) => item.id;
const ancientArtifactLabel = (item: AncientArtifact) => `#${item.displayId} ${item.name}`; const ancientArtifactLabel = (item: AncientArtifact) => item.name;
const recipeKey = (item: Recipe) => item.id; const recipeKey = (item: Recipe) => item.id;
const recipeLabel = (item: Recipe) => item.name; const recipeLabel = (item: Recipe) => item.name;
const dishCategoryKey = (item: DishCategory) => item.id;
const dishCategoryLabel = (item: DishCategory) => item.name;
const dishKey = (item: Dish) => item.id;
const dishLabel = (item: Dish) => item.item.name;
const habitatKey = (item: Habitat) => item.id; const habitatKey = (item: Habitat) => item.id;
const habitatLabel = (item: Habitat) => item.name; const habitatLabel = (item: Habitat) => item.name;
@@ -525,6 +568,31 @@ function resetChecklistForm() {
checklistForm.value = { id: 0, title: '', translations: {} }; checklistForm.value = { id: 0, title: '', translations: {} };
} }
function resetDishCategoryForm() {
dishCategoryForm.value = {
id: 0,
name: '',
effect: '',
translations: {},
cookwareItemId: '',
mainMaterialItemId: '',
totalMaterialQuantity: 2
};
}
function resetDishForm() {
dishForm.value = {
id: 0,
categoryId: dishCategoryRows.value[0] ? String(dishCategoryRows.value[0].id) : '',
itemId: '',
flavorId: '',
translations: {},
secondaryMaterialItemIds: ['', ''],
pokemonSkillId: '',
mosslaxEffect: ''
};
}
function resetLanguageForm() { function resetLanguageForm() {
languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }; languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 };
editingLanguageCode.value = ''; editingLanguageCode.value = '';
@@ -621,6 +689,53 @@ function editChecklistItem(item: DailyChecklistItem) {
checklistModalOpen.value = true; checklistModalOpen.value = true;
} }
function openNewDishCategory() {
resetDishCategoryForm();
dishCategoryModalOpen.value = true;
}
function closeDishCategoryModal() {
dishCategoryModalOpen.value = false;
resetDishCategoryForm();
}
function editDishCategory(item: DishCategory) {
dishCategoryForm.value = {
id: item.id,
name: item.baseName ?? item.name,
effect: item.baseEffect ?? item.effect,
translations: item.translations ?? {},
cookwareItemId: String(item.cookware.id),
mainMaterialItemId: String(item.mainMaterial.id),
totalMaterialQuantity: item.totalMaterialQuantity
};
dishCategoryModalOpen.value = true;
}
function openNewDish() {
resetDishForm();
dishModalOpen.value = true;
}
function closeDishModal() {
dishModalOpen.value = false;
resetDishForm();
}
function editDish(item: Dish) {
dishForm.value = {
id: item.id,
categoryId: String(item.category.id),
itemId: String(item.item.id),
flavorId: String(item.flavor.id),
translations: item.translations ?? {},
secondaryMaterialItemIds: [String(item.secondaryMaterials[0]?.id ?? ''), String(item.secondaryMaterials[1]?.id ?? '')],
pokemonSkillId: String(item.pokemonSkill?.id ?? ''),
mosslaxEffect: item.baseMosslaxEffect ?? item.mosslaxEffect
};
dishModalOpen.value = true;
}
function openNewLanguage() { function openNewLanguage() {
resetLanguageForm(); resetLanguageForm();
languageModalOpen.value = true; languageModalOpen.value = true;
@@ -786,6 +901,21 @@ function previewRecipeOrder(rows: Recipe[]) {
recipeRows.value = rows; recipeRows.value = rows;
} }
function previewDishCategoryOrder(rows: DishCategory[]) {
dishCategoryRows.value = rows;
}
function previewDishOrder(rows: Dish[]) {
const rowsById = new Map(rows.map((row) => [row.id, row]));
const orderById = new Map(rows.map((row, index) => [row.id, index]));
dishCategoryRows.value = dishCategoryRows.value.map((category) => ({
...category,
dishes: category.dishes
.map((dish) => rowsById.get(dish.id) ?? dish)
.sort((a, b) => (orderById.get(a.id) ?? 0) - (orderById.get(b.id) ?? 0))
}));
}
function previewHabitatOrder(rows: Habitat[]) { function previewHabitatOrder(rows: Habitat[]) {
habitatRows.value = rows; habitatRows.value = rows;
} }
@@ -875,6 +1005,30 @@ async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
}); });
} }
async function persistDishCategoryOrder(nextRows: DishCategory[], fallbackRows: DishCategory[]) {
dishCategoryRows.value = nextRows;
await run(async () => {
try {
dishCategoryRows.value = await api.reorderDishCategories(nextRows.map((item) => item.id));
} catch (error) {
dishCategoryRows.value = fallbackRows;
throw error;
}
});
}
async function persistDishOrder(nextRows: Dish[], fallbackRows: Dish[]) {
previewDishOrder(nextRows);
await run(async () => {
try {
dishCategoryRows.value = await api.reorderDishes(nextRows.map((item) => item.id));
} catch (error) {
previewDishOrder(fallbackRows);
throw error;
}
});
}
async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) { async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) {
habitatRows.value = nextRows; habitatRows.value = nextRows;
await run(async () => { await run(async () => {
@@ -935,6 +1089,59 @@ async function saveChecklistItem() {
}); });
} }
function dishCategoryPayloadForSave() {
return {
name: dishCategoryForm.value.name,
effect: dishCategoryForm.value.effect,
translations: dishCategoryForm.value.translations,
cookwareItemId: Number(dishCategoryForm.value.cookwareItemId),
mainMaterialItemId: Number(dishCategoryForm.value.mainMaterialItemId),
totalMaterialQuantity: Number(dishCategoryForm.value.totalMaterialQuantity)
};
}
function dishPayloadForSave() {
const secondaryMaterialItemIds = dishForm.value.secondaryMaterialItemIds
.map((itemId) => Number(itemId))
.filter((itemId) => Number.isInteger(itemId) && itemId > 0);
return {
categoryId: Number(dishForm.value.categoryId),
itemId: Number(dishForm.value.itemId),
flavorId: Number(dishForm.value.flavorId),
translations: dishForm.value.translations,
secondaryMaterialItemIds: dishAllowsSecondSecondaryMaterial.value ? secondaryMaterialItemIds : secondaryMaterialItemIds.slice(0, 1),
pokemonSkillId: dishForm.value.pokemonSkillId ? Number(dishForm.value.pokemonSkillId) : null,
mosslaxEffect: dishForm.value.mosslaxEffect
};
}
async function saveDishCategory() {
await run(async () => {
const payload = dishCategoryPayloadForSave();
if (dishCategoryForm.value.id) {
await api.updateDishCategory(dishCategoryForm.value.id, payload);
} else {
await api.createDishCategory(payload);
}
await loadDishAdmin();
closeDishCategoryModal();
});
}
async function saveDish() {
await run(async () => {
const payload = dishPayloadForSave();
if (dishForm.value.id) {
await api.updateDish(dishForm.value.id, payload);
} else {
await api.createDish(payload);
}
await loadDishAdmin();
closeDishModal();
});
}
async function saveLanguage() { async function saveLanguage() {
await run(async () => { await run(async () => {
const payload = { const payload = {
@@ -978,6 +1185,18 @@ async function loadRecipes() {
recipeRows.value = await api.recipes(); recipeRows.value = await api.recipes();
} }
async function loadDishAdmin() {
await loadLanguages();
const [dishCategories, items, options] = await Promise.all([api.dish(), api.items({}), api.options()]);
dishCategoryRows.value = dishCategories;
dishItemRows.value = items;
dishSkillRows.value = options.skills;
dishFlavorRows.value = options.dishFlavors;
if (!dishForm.value.id && !dishForm.value.categoryId) {
resetDishForm();
}
}
async function loadHabitats() { async function loadHabitats() {
habitatRows.value = await api.habitats(); habitatRows.value = await api.habitats();
} }
@@ -1153,6 +1372,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'items') await loadItems(); if (activeTab.value === 'items') await loadItems();
if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts(); if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts();
if (activeTab.value === 'recipes') await loadRecipes(); if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'dish') await loadDishAdmin();
if (activeTab.value === 'habitats') await loadHabitats(); if (activeTab.value === 'habitats') await loadHabitats();
} finally { } finally {
if (showSkeleton) { if (showSkeleton) {
@@ -1253,6 +1473,26 @@ async function removeRecipe(id: number) {
}); });
} }
async function removeDishCategory(id: number) {
await run(async () => {
await api.deleteDishCategory(id);
if (dishCategoryForm.value.id === id) {
closeDishCategoryModal();
}
await loadDishAdmin();
});
}
async function removeDish(id: number) {
await run(async () => {
await api.deleteDish(id);
if (dishForm.value.id === id) {
closeDishModal();
}
await loadDishAdmin();
});
}
async function removeHabitat(id: number) { async function removeHabitat(id: number) {
await run(async () => { await run(async () => {
await api.deleteHabitat(id); await api.deleteHabitat(id);
@@ -2020,7 +2260,7 @@ onMounted(() => {
@reorder="persistItemOrder" @reorder="persistItemOrder"
> >
<template #default="{ item }"> <template #default="{ item }">
<RouterLink :to="`/items/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink> <RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)"> <button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
@@ -2048,7 +2288,7 @@ onMounted(() => {
@reorder="persistAncientArtifactOrder" @reorder="persistAncientArtifactOrder"
> >
<template #default="{ item }"> <template #default="{ item }">
<RouterLink :to="`/ancient-artifacts/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink> <RouterLink :to="`/ancient-artifacts/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button v-if="can('ancient-artifacts.delete')" type="button" :disabled="busy" @click="removeAncientArtifact(item.id)"> <button v-if="can('ancient-artifacts.delete')" type="button" :disabled="busy" @click="removeAncientArtifact(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
@@ -2088,6 +2328,84 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'dish'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.dishList') }}</h2>
<span class="row-actions">
<button v-if="can('dish.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewDishCategory">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newCategory') }}
</button>
<button v-if="can('dish.create')" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy || !dishCategoryRows.length" @click="openNewDish">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newDish') }}
</button>
</span>
</div>
<h3 class="section-subtitle">{{ t('pages.dish.categories') }}</h3>
<ReorderableList
v-if="dishCategoryRows.length"
:items="dishCategoryRows"
:item-key="dishCategoryKey"
:item-label="dishCategoryLabel"
list-key-prefix="dish-categories"
:disabled="busy || !can('dish.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewDishCategoryOrder"
@cancel="previewDishCategoryOrder"
@reorder="persistDishCategoryOrder"
>
<template #default="{ item }">
<span class="reorderable-row-title">{{ item.name }}</span>
<span class="meta-line">{{ item.cookware.name }} / {{ item.mainMaterial.name }} / {{ item.totalMaterialQuantity }}</span>
<span class="row-actions">
<button v-if="can('dish.update')" type="button" :disabled="busy" @click="editDishCategory(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('dish.delete')" type="button" :disabled="busy" @click="removeDishCategory(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
<h3 class="section-subtitle">{{ t('pages.dish.dishes') }}</h3>
<ReorderableList
v-if="dishRows.length"
:items="dishRows"
:item-key="dishKey"
:item-label="dishLabel"
list-key-prefix="dishes"
:disabled="busy || !can('dish.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewDishOrder"
@cancel="previewDishOrder"
@reorder="persistDishOrder"
>
<template #default="{ item }">
<RouterLink :to="`/items/${item.item.id}`">{{ item.item.name }}</RouterLink>
<span class="meta-line">{{ item.category.name }} / {{ item.flavor.name }}</span>
<span class="row-actions">
<button v-if="can('dish.update')" type="button" :disabled="busy" @click="editDish(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('dish.delete')" type="button" :disabled="busy" @click="removeDish(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
<h2>{{ t('pages.admin.habitatList') }}</h2> <h2>{{ t('pages.admin.habitatList') }}</h2>
<ReorderableList <ReorderableList
@@ -2324,6 +2642,131 @@ onMounted(() => {
</template> </template>
</Modal> </Modal>
<Modal v-if="dishCategoryModalOpen" :title="dishCategoryModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishCategoryModal">
<form id="admin-dish-category-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDishCategory">
<div class="dish-form-row dish-form-row--4">
<TranslationFields
id-prefix="dish-category-name"
v-model:base-value="dishCategoryForm.name"
v-model:translations="dishCategoryForm.translations"
field="name"
:label="t('common.name')"
:languages="languageRows"
required
/>
<div class="field">
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
<input id="dish-category-total-material-quantity" v-model.number="dishCategoryForm.totalMaterialQuantity" type="number" min="2" required />
</div>
<div class="field">
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
</div>
</div>
<TranslationFields
id-prefix="dish-category-effect"
v-model:base-value="dishCategoryForm.effect"
v-model:translations="dishCategoryForm.translations"
field="effect"
:label="t('pages.dish.effect')"
:languages="languageRows"
required
/>
</form>
<template #footer>
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishCategoryModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="dishModalOpen" :title="dishModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishModal">
<form id="admin-dish-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDish">
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-category">{{ t('pages.dish.category') }}</label>
<select id="dish-category" v-model="dishForm.categoryId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
<select id="dish-item" v-model="dishForm.itemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
<select id="dish-flavor" v-model="dishForm.flavorId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option>
</select>
</div>
</div>
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]">
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
</div>
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]">
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">{{ item.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId">
<option value="">{{ t('common.none') }}</option>
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option>
</select>
</div>
</div>
<TranslationFields
id-prefix="dish-mosslax-effect"
v-model:base-value="dishForm.mosslaxEffect"
v-model:translations="dishForm.translations"
field="mosslaxEffect"
:label="t('pages.dish.mosslaxEffect')"
:languages="languageRows"
required
/>
</form>
<template #footer>
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="configModalOpen" :title="configModalTitle" :close-label="t('common.close')" @close="closeConfigModal"> <Modal v-if="configModalOpen" :title="configModalTitle" :close-label="t('common.close')" @close="closeConfigModal">
<form id="admin-config-form" class="modal-edit-form" @submit.prevent="saveConfig"> <form id="admin-config-form" class="modal-edit-form" @submit.prevent="saveConfig">
<div class="field"> <div class="field">

View File

@@ -96,7 +96,7 @@ watch(
</section> </section>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${artifact.displayId} ${artifact.name}`" :subtitle="artifact.category.name"> <PageHeader :title="artifact.name" :subtitle="artifact.category.name">
<template #kicker>{{ t('pages.ancientArtifacts.detailKicker') }}</template> <template #kicker>{{ t('pages.ancientArtifacts.detailKicker') }}</template>
<template #actions> <template #actions>
<RouterLink v-if="canUpdateArtifact" class="ui-button ui-button--primary ui-button--small" :to="`/ancient-artifacts/${artifact.id}/edit`"> <RouterLink v-if="canUpdateArtifact" class="ui-button ui-button--primary ui-button--small" :to="`/ancient-artifacts/${artifact.id}/edit`">
@@ -116,10 +116,6 @@ watch(
<div v-if="detailTab === 'details'" class="detail-grid"> <div v-if="detailTab === 'details'" class="detail-grid">
<DetailSection :title="t('common.details')"> <DetailSection :title="t('common.details')">
<dl class="entity-profile-facts"> <dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.ancientArtifacts.displayId') }}</dt>
<dd>#{{ artifact.displayId }}</dd>
</div>
<div> <div>
<dt>{{ t('pages.ancientArtifacts.category') }}</dt> <dt>{{ t('pages.ancientArtifacts.category') }}</dt>
<dd>{{ artifact.category.name }}</dd> <dd>{{ artifact.category.name }}</dd>

View File

@@ -36,7 +36,6 @@ const busy = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
const artifactForm = ref({ const artifactForm = ref({
displayId: 1,
name: '', name: '',
details: '', details: '',
translations: {} as TranslationMap, translations: {} as TranslationMap,
@@ -98,7 +97,6 @@ async function loadEditor() {
if (isEditing.value) { if (isEditing.value) {
const artifact = await api.ancientArtifactDetail(routeId.value); const artifact = await api.ancientArtifactDetail(routeId.value);
artifactForm.value = { artifactForm.value = {
displayId: artifact.displayId,
name: artifact.baseName ?? artifact.name, name: artifact.baseName ?? artifact.name,
details: artifact.baseDetails ?? artifact.details, details: artifact.baseDetails ?? artifact.details,
translations: artifact.translations ?? {}, translations: artifact.translations ?? {},
@@ -142,7 +140,6 @@ async function saveArtifact() {
try { try {
const payload: AncientArtifactPayload = { const payload: AncientArtifactPayload = {
displayId: artifactForm.value.displayId,
name: artifactNameForSave(), name: artifactNameForSave(),
details: artifactForm.value.details, details: artifactForm.value.details,
translations: artifactForm.value.translations, translations: artifactForm.value.translations,
@@ -190,11 +187,6 @@ onMounted(() => {
required required
/> />
<div class="field">
<label for="artifact-display-id">{{ t('pages.ancientArtifacts.displayId') }}</label>
<input id="artifact-display-id" v-model.number="artifactForm.displayId" type="number" min="1" required />
</div>
<TranslationFields <TranslationFields
id-prefix="artifact-details" id-prefix="artifact-details"
v-model:base-value="artifactForm.details" v-model:base-value="artifactForm.details"

View File

@@ -113,8 +113,12 @@ watch(artifactQuery, loadArtifacts);
</div> </div>
</FilterPanel> </FilterPanel>
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingList')"> <div v-if="loading" class="entity-grid catalog-card-grid collections-card-grid" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingList')">
<article v-for="index in skeletonCardCount" :key="`artifact-skeleton-${index}`" class="entity-card entity-card--skeleton"> <article
v-for="index in skeletonCardCount"
:key="`artifact-skeleton-${index}`"
class="entity-card entity-card--skeleton entity-card--collection-compact"
>
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" /> <Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content"> <div class="entity-card__content">
<Skeleton width="128px" height="24px" /> <Skeleton width="128px" height="24px" />
@@ -122,15 +126,16 @@ watch(artifactQuery, loadArtifacts);
</div> </div>
</article> </article>
</div> </div>
<div v-else class="entity-grid catalog-card-grid"> <div v-else class="entity-grid catalog-card-grid collections-card-grid">
<EntityCard <EntityCard
v-for="artifact in artifacts" v-for="artifact in artifacts"
:key="artifact.id" :key="artifact.id"
:title="`#${artifact.displayId} ${artifact.name}`" :title="artifact.name"
:subtitle="artifact.category.name" :subtitle="artifact.category.name"
:to="`/ancient-artifacts/${artifact.id}`" :to="`/ancient-artifacts/${artifact.id}`"
:icon="iconArtifact" :icon="iconArtifact"
:image="artifactCardImage(artifact)" :image="artifactCardImage(artifact)"
compact-tooltip
/> />
</div> </div>

View File

@@ -0,0 +1,609 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EntityChips from '../components/EntityChips.vue';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type Dish,
type DishCategory,
type Item,
type ItemLink,
type Language,
type NamedEntity,
type Skill,
type TranslationMap
} from '../services/api';
const { t } = useI18n();
const categories = ref<DishCategory[]>([]);
const activeCategoryId = ref('');
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const currentUser = ref<AuthUser | null>(null);
const languages = ref<Language[]>([]);
const items = ref<Item[]>([]);
const skills = ref<Skill[]>([]);
const dishFlavors = ref<NamedEntity[]>([]);
const dishCategoryModalOpen = ref(false);
const dishModalOpen = ref(false);
const dishCategoryForm = ref({
id: 0,
name: '',
effect: '',
translations: {} as TranslationMap,
cookwareItemId: '',
mainMaterialItemId: '',
totalMaterialQuantity: 2
});
const dishForm = ref({
id: 0,
categoryId: '',
itemId: '',
flavorId: '',
translations: {} as TranslationMap,
secondaryMaterialItemIds: ['', ''],
pokemonSkillId: '',
mosslaxEffect: ''
});
const categoryTabs = computed<TabOption[]>(() =>
categories.value.map((category) => ({ value: String(category.id), label: category.name }))
);
const activeCategory = computed(() =>
categories.value.find((category) => String(category.id) === activeCategoryId.value) ?? categories.value[0] ?? null
);
const canCreateDish = computed(() => currentUser.value?.permissions.includes('dish.create') === true);
const canUpdateDish = computed(() => currentUser.value?.permissions.includes('dish.update') === true);
const canDeleteDish = computed(() => currentUser.value?.permissions.includes('dish.delete') === true);
const selectedDishFormCategory = computed(() => categories.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
const dishCategoryModalTitle = computed(() =>
dishCategoryForm.value.id ? t('pages.dish.editCategory') : t('pages.dish.newCategory')
);
const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish')));
const itemSelectOptions = computed<TagsSelectOption[]>(() =>
items.value.map((item) => ({ id: item.id, name: item.name }))
);
const optionalItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...itemSelectOptions.value]);
const categorySelectOptions = computed<TagsSelectOption[]>(() => categories.value.map((category) => ({ id: category.id, name: category.name })));
const flavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavors.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
const optionalSkillSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...skills.value]);
const dishCategoryFormValid = computed(
() =>
dishCategoryForm.value.name.trim() !== '' &&
dishCategoryForm.value.effect.trim() !== '' &&
dishCategoryForm.value.cookwareItemId !== '' &&
dishCategoryForm.value.mainMaterialItemId !== '' &&
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
);
const dishFormValid = computed(
() =>
dishForm.value.categoryId !== '' &&
dishForm.value.itemId !== '' &&
dishForm.value.flavorId !== '' &&
dishForm.value.mosslaxEffect.trim() !== ''
);
function itemImage(item: ItemLink) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null;
}
function resetDishCategoryForm() {
dishCategoryForm.value = {
id: 0,
name: '',
effect: '',
translations: {},
cookwareItemId: '',
mainMaterialItemId: '',
totalMaterialQuantity: 2
};
}
function resetDishForm() {
dishForm.value = {
id: 0,
categoryId: activeCategory.value ? String(activeCategory.value.id) : categories.value[0] ? String(categories.value[0].id) : '',
itemId: '',
flavorId: '',
translations: {},
secondaryMaterialItemIds: ['', ''],
pokemonSkillId: '',
mosslaxEffect: ''
};
}
function openNewDishCategory() {
resetDishCategoryForm();
dishCategoryModalOpen.value = true;
}
function editDishCategory(category: DishCategory) {
dishCategoryForm.value = {
id: category.id,
name: category.baseName ?? category.name,
effect: category.baseEffect ?? category.effect,
translations: category.translations ?? {},
cookwareItemId: String(category.cookware.id),
mainMaterialItemId: String(category.mainMaterial.id),
totalMaterialQuantity: category.totalMaterialQuantity
};
dishCategoryModalOpen.value = true;
}
function closeDishCategoryModal() {
dishCategoryModalOpen.value = false;
resetDishCategoryForm();
}
function openNewDish() {
resetDishForm();
dishModalOpen.value = true;
}
function editDish(dish: Dish) {
dishForm.value = {
id: dish.id,
categoryId: String(dish.category.id),
itemId: String(dish.item.id),
flavorId: String(dish.flavor.id),
translations: dish.translations ?? {},
secondaryMaterialItemIds: [String(dish.secondaryMaterials[0]?.id ?? ''), String(dish.secondaryMaterials[1]?.id ?? '')],
pokemonSkillId: String(dish.pokemonSkill?.id ?? ''),
mosslaxEffect: dish.baseMosslaxEffect ?? dish.mosslaxEffect
};
dishModalOpen.value = true;
}
function closeDishModal() {
dishModalOpen.value = false;
resetDishForm();
}
function dishCategoryPayloadForSave() {
return {
name: dishCategoryForm.value.name,
effect: dishCategoryForm.value.effect,
translations: dishCategoryForm.value.translations,
cookwareItemId: Number(dishCategoryForm.value.cookwareItemId),
mainMaterialItemId: Number(dishCategoryForm.value.mainMaterialItemId),
totalMaterialQuantity: Number(dishCategoryForm.value.totalMaterialQuantity)
};
}
function dishPayloadForSave() {
const secondaryMaterialItemIds = dishForm.value.secondaryMaterialItemIds
.map((itemId) => Number(itemId))
.filter((itemId) => Number.isInteger(itemId) && itemId > 0);
return {
categoryId: Number(dishForm.value.categoryId),
itemId: Number(dishForm.value.itemId),
flavorId: Number(dishForm.value.flavorId),
translations: dishForm.value.translations,
secondaryMaterialItemIds: dishAllowsSecondSecondaryMaterial.value ? secondaryMaterialItemIds : secondaryMaterialItemIds.slice(0, 1),
pokemonSkillId: dishForm.value.pokemonSkillId ? Number(dishForm.value.pokemonSkillId) : null,
mosslaxEffect: dishForm.value.mosslaxEffect
};
}
function errorText(error: unknown) {
return error instanceof Error && error.message ? error.message : t('errors.operationFailed');
}
async function run(action: () => Promise<void>) {
busy.value = true;
message.value = '';
try {
await action();
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function loadDish(showSkeleton = false) {
if (showSkeleton) {
loading.value = true;
}
categories.value = await api.dish();
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
loading.value = false;
}
async function saveDishCategory() {
if (!dishCategoryFormValid.value) {
return;
}
await run(async () => {
const payload = dishCategoryPayloadForSave();
if (dishCategoryForm.value.id) {
await api.updateDishCategory(dishCategoryForm.value.id, payload);
} else {
await api.createDishCategory(payload);
}
await loadDish();
closeDishCategoryModal();
});
}
async function saveDish() {
if (!dishFormValid.value) {
return;
}
await run(async () => {
const payload = dishPayloadForSave();
if (dishForm.value.id) {
await api.updateDish(dishForm.value.id, payload);
} else {
await api.createDish(payload);
}
await loadDish();
closeDishModal();
});
}
async function removeDishCategory(id: number) {
await run(async () => {
await api.deleteDishCategory(id);
await loadDish();
});
}
async function removeDish(id: number) {
await run(async () => {
await api.deleteDish(id);
await loadDish();
});
}
async function loadEditorOptions() {
const [nextLanguages, nextItems, nextOptions] = await Promise.all([api.languages(), api.items({}), api.options()]);
languages.value = nextLanguages;
items.value = nextItems;
skills.value = nextOptions.skills;
dishFlavors.value = nextOptions.dishFlavors;
}
async function loadPage() {
loading.value = true;
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await Promise.all([loadDish(), loadEditorOptions()]);
}
watch(categories, (nextCategories) => {
if (!nextCategories.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = nextCategories[0] ? String(nextCategories[0].id) : '';
}
});
watch(
() => dishForm.value.categoryId,
() => {
if (!dishAllowsSecondSecondaryMaterial.value) {
dishForm.value.secondaryMaterialItemIds[1] = '';
}
}
);
onMounted(loadPage);
</script>
<template>
<section class="page-stack dish-page">
<PageHeader :title="t('pages.dish.title')" :subtitle="t('pages.dish.subtitle')">
<template #kicker>{{ t('pages.dish.kicker') }}</template>
<template #actions>
<button v-if="canCreateDish" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewDishCategory">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newCategory') }}
</button>
<button v-if="canCreateDish" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy || !categories.length" @click="openNewDish">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newDish') }}
</button>
</template>
</PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<div v-if="loading" class="tabs tabs--component" aria-hidden="true">
<div class="tab-list tab-list--skeleton">
<Skeleton v-for="width in ['86px', '112px', '96px']" :key="width" variant="box" :width="width" height="42px" class="skeleton-tab" />
</div>
</div>
<Tabs v-else-if="categoryTabs.length" id="dish-category-tabs" v-model="activeCategoryId" :tabs="categoryTabs" :label="t('pages.dish.category')" />
<section v-if="loading" class="detail-section dish-category-panel" aria-busy="true" :aria-label="t('pages.dish.loading')">
<div class="dish-category-summary">
<Skeleton variant="box" width="96px" height="96px" class="skeleton-entity-mark" />
<div class="dish-category-summary__content">
<Skeleton width="180px" height="28px" />
<Skeleton width="100%" />
<Skeleton width="72%" />
</div>
</div>
<div class="dish-grid">
<article v-for="index in 4" :key="`dish-skeleton-${index}`" class="dish-card">
<Skeleton variant="box" width="72px" height="72px" class="skeleton-entity-mark" />
<div class="dish-card__content">
<Skeleton width="140px" height="24px" />
<Skeleton width="92px" />
<Skeleton width="100%" />
</div>
</article>
</div>
</section>
<section v-else-if="activeCategory" class="detail-section dish-category-panel">
<div class="dish-category-summary">
<RouterLink class="dish-media-link" :to="`/items/${activeCategory.cookware.id}`">
<img
v-if="itemImage(activeCategory.cookware)"
:src="itemImage(activeCategory.cookware)?.src"
:alt="itemImage(activeCategory.cookware)?.alt"
loading="lazy"
/>
<Icon v-else :icon="iconDish" class="entity-card__icon" aria-hidden="true" />
</RouterLink>
<div class="dish-category-summary__content">
<h2>{{ activeCategory.name }}</h2>
<dl class="info-list">
<div>
<dt>{{ t('pages.dish.cookware') }}</dt>
<dd>
<RouterLink :to="`/items/${activeCategory.cookware.id}`">{{ activeCategory.cookware.name }}</RouterLink>
</dd>
</div>
<div>
<dt>{{ t('pages.dish.totalMaterialQuantity') }}</dt>
<dd>{{ activeCategory.totalMaterialQuantity }}</dd>
</div>
<div>
<dt>{{ t('pages.dish.mainMaterial') }}</dt>
<dd>
<RouterLink :to="`/items/${activeCategory.mainMaterial.id}`">{{ activeCategory.mainMaterial.name }}</RouterLink>
</dd>
</div>
</dl>
<div class="dish-category-effect-row">
<strong>{{ t('pages.dish.effect') }}</strong>
<span>{{ activeCategory.effect }}</span>
</div>
<div v-if="canUpdateDish || canDeleteDish" class="row-actions">
<button v-if="canUpdateDish" type="button" :disabled="busy" @click="editDishCategory(activeCategory)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="canDeleteDish" type="button" :disabled="busy" @click="removeDishCategory(activeCategory.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<div class="dish-grid">
<article v-for="dish in activeCategory.dishes" :key="dish.id" class="dish-card">
<RouterLink class="dish-media-link dish-media-link--small" :to="`/items/${dish.item.id}`">
<img v-if="dish.item.image" :src="dish.item.image.url" :alt="t('media.imageAlt', { name: dish.item.name })" loading="lazy" />
<Icon v-else :icon="iconItem" class="entity-card__icon" aria-hidden="true" />
</RouterLink>
<div class="dish-card__content">
<RouterLink class="dish-card__title" :to="`/items/${dish.item.id}`">{{ dish.item.name }}</RouterLink>
<div class="dish-card__meta">
<span>{{ dish.flavor.name }}</span>
<span v-if="dish.pokemonSkill">{{ dish.pokemonSkill.name }}</span>
</div>
<dl class="info-list info-list--compact">
<div>
<dt>{{ t('pages.dish.secondaryMaterials') }}</dt>
<dd>
<EntityChips v-if="dish.secondaryMaterials.length" :items="dish.secondaryMaterials" />
<span v-else>{{ t('common.none') }}</span>
</dd>
</div>
<div>
<dt>{{ t('pages.dish.mosslaxEffect') }}</dt>
<dd>{{ dish.mosslaxEffect }}</dd>
</div>
</dl>
<div v-if="canUpdateDish || canDeleteDish" class="row-actions">
<button v-if="canUpdateDish" type="button" :disabled="busy" @click="editDish(dish)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="canDeleteDish" type="button" :disabled="busy" @click="removeDish(dish.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</div>
</div>
</article>
</div>
<p v-if="!activeCategory.dishes.length" class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else class="detail-section">
<p class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<Modal v-if="dishCategoryModalOpen" :title="dishCategoryModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishCategoryModal">
<form id="dish-category-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDishCategory">
<div class="dish-form-row dish-form-row--4">
<TranslationFields
id-prefix="dish-category-name"
v-model:base-value="dishCategoryForm.name"
v-model:translations="dishCategoryForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field">
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
<TagsSelect
id="dish-category-cookware"
v-model="dishCategoryForm.cookwareItemId"
:options="itemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
<input id="dish-category-total-material-quantity" v-model.number="dishCategoryForm.totalMaterialQuantity" type="number" min="2" required />
</div>
<div class="field">
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
<TagsSelect
id="dish-category-main-material"
v-model="dishCategoryForm.mainMaterialItemId"
:options="itemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
</div>
<TranslationFields
id-prefix="dish-category-effect"
v-model:base-value="dishCategoryForm.effect"
v-model:translations="dishCategoryForm.translations"
field="effect"
:label="t('pages.dish.effect')"
:languages="languages"
required
/>
</form>
<template #footer>
<button type="submit" form="dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishCategoryModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="dishModalOpen" :title="dishModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishModal">
<form id="dish-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDish">
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-category">{{ t('pages.dish.category') }}</label>
<TagsSelect
id="dish-category"
v-model="dishForm.categoryId"
:options="categorySelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.category')"
/>
</div>
<div class="field">
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
<TagsSelect
id="dish-item"
v-model="dishForm.itemId"
:options="itemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
<TagsSelect
id="dish-flavor"
v-model="dishForm.flavorId"
:options="flavorSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.flavor')"
/>
</div>
</div>
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
<TagsSelect
id="dish-secondary-material-1"
v-model="dishForm.secondaryMaterialItemIds[0]"
:options="optionalItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
<TagsSelect
id="dish-secondary-material-2"
v-model="dishForm.secondaryMaterialItemIds[1]"
:options="optionalItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
<TagsSelect
id="dish-pokemon-skill"
v-model="dishForm.pokemonSkillId"
:options="optionalSkillSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.dish.pokemonSkill')"
/>
</div>
</div>
<TranslationFields
id-prefix="dish-mosslax-effect"
v-model:base-value="dishForm.mosslaxEffect"
v-model:translations="dishForm.translations"
field="mosslaxEffect"
:label="t('pages.dish.mosslaxEffect')"
:languages="languages"
required
/>
</form>
<template #footer>
<button type="submit" form="dish-form" class="link-button" :disabled="busy || !dishFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
</section>
</template>

View File

@@ -149,7 +149,7 @@ watch(
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${item.displayId} ${item.name}`" :subtitle="itemSubtitle"> <PageHeader :title="item.name" :subtitle="itemSubtitle">
<template #kicker>{{ detailKicker }}</template> <template #kicker>{{ detailKicker }}</template>
<template #actions> <template #actions>
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`"> <RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
@@ -182,10 +182,6 @@ watch(
<div class="entity-profile-main"> <div class="entity-profile-main">
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')"> <section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
<dl class="entity-profile-facts"> <dl class="entity-profile-facts">
<div>
<dt>{{ t('pages.items.displayId') }}</dt>
<dd>#{{ item.displayId }}</dd>
</div>
<div> <div>
<dt>{{ t('pages.items.category') }}</dt> <dt>{{ t('pages.items.category') }}</dt>
<dd>{{ item.category.name }}</dd> <dd>{{ item.category.name }}</dd>

View File

@@ -36,7 +36,6 @@ const busy = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
const itemForm = ref({ const itemForm = ref({
displayId: 1,
name: '', name: '',
details: '', details: '',
translations: {} as TranslationMap, translations: {} as TranslationMap,
@@ -117,7 +116,6 @@ async function loadEditor() {
if (isEditing.value) { if (isEditing.value) {
const item = await api.itemDetail(routeId.value); const item = await api.itemDetail(routeId.value);
itemForm.value = { itemForm.value = {
displayId: item.displayId,
name: item.baseName ?? item.name, name: item.baseName ?? item.name,
details: item.baseDetails ?? item.details, details: item.baseDetails ?? item.details,
translations: item.translations ?? {}, translations: item.translations ?? {},
@@ -173,7 +171,6 @@ async function saveItem() {
try { try {
const payload: ItemPayload = { const payload: ItemPayload = {
displayId: itemForm.value.displayId,
name: itemNameForSave(), name: itemNameForSave(),
details: itemForm.value.details, details: itemForm.value.details,
translations: itemForm.value.translations, translations: itemForm.value.translations,
@@ -226,11 +223,6 @@ onMounted(() => {
required required
/> />
<div class="field">
<label for="item-display-id">{{ t('pages.items.displayId') }}</label>
<input id="item-display-id" v-model.number="itemForm.displayId" type="number" min="1" required />
</div>
<TranslationFields <TranslationFields
id-prefix="item-details" id-prefix="item-details"
v-model:base-value="itemForm.details" v-model:base-value="itemForm.details"

View File

@@ -132,8 +132,12 @@ watch(itemQuery, loadItems);
</div> </div>
</FilterPanel> </FilterPanel>
<div v-if="loading" class="entity-grid catalog-card-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')"> <div v-if="loading" class="entity-grid catalog-card-grid collections-card-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')">
<article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton"> <article
v-for="index in skeletonCardCount"
:key="`item-skeleton-${index}`"
class="entity-card entity-card--skeleton entity-card--collection-compact"
>
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" /> <Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
<div class="entity-card__content"> <div class="entity-card__content">
<Skeleton width="128px" height="24px" /> <Skeleton width="128px" height="24px" />
@@ -141,16 +145,17 @@ watch(itemQuery, loadItems);
</div> </div>
</article> </article>
</div> </div>
<div v-else class="entity-grid catalog-card-grid"> <div v-else class="entity-grid catalog-card-grid collections-card-grid">
<EntityCard <EntityCard
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
:title="`#${item.displayId} ${item.name}`" :title="item.name"
:subtitle="item.category.name" :subtitle="item.category.name"
:to="`/items/${item.id}`" :to="`/items/${item.id}`"
:icon="iconItem" :icon="iconItem"
:image="itemCardImage(item)" :image="itemCardImage(item)"
:ribbon="item.usage?.name" :ribbon="item.usage?.name"
compact-tooltip
/> />
</div> </div>

View File

@@ -33,6 +33,7 @@ import {
setAuthToken, setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort,
type LifeComment, type LifeComment,
type LifePost, type LifePost,
type LifeReactionType, type LifeReactionType,
@@ -53,6 +54,7 @@ const commentsLoading = ref(false);
const commentsLoadingMore = ref(false); const commentsLoadingMore = ref(false);
const commentsLoaded = ref(false); const commentsLoaded = ref(false);
const commentsError = ref(''); const commentsError = ref('');
const activeCommentSort = ref<CommentSort>('oldest');
const commentBodies = ref<Record<number, string>>({}); const commentBodies = ref<Record<number, string>>({});
const replyBodies = ref<Record<number, string>>({}); const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null); const replyTargetId = ref<number | null>(null);
@@ -82,9 +84,16 @@ function can(permissionKey: string) {
} }
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set')); const canRate = computed(() => can('life.ratings.set'));
const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved'); const canCommentOnPost = computed(() => canComment.value && post.value?.moderationStatus === 'approved');
const commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'most-liked', label: t('pages.life.sortMostLiked') },
{ value: 'most-replied', label: t('pages.life.sortMostReplied') }
]);
function routePostId() { function routePostId() {
const value = route.params.id; const value = route.params.id;
@@ -185,7 +194,7 @@ async function loadComments(reset = false) {
} }
try { try {
const page = await api.lifeComments(currentPost.id, { limit: lifeCommentPageSize, cursor }); const page = await api.lifeComments(currentPost.id, { limit: lifeCommentPageSize, cursor, sort: activeCommentSort.value });
comments.value = reset || !commentsLoaded.value ? page.items : mergeComments(comments.value, page.items); comments.value = reset || !commentsLoaded.value ? page.items : mergeComments(comments.value, page.items);
commentsNextCursor.value = page.nextCursor; commentsNextCursor.value = page.nextCursor;
commentsHasMore.value = page.hasMore; commentsHasMore.value = page.hasMore;
@@ -208,6 +217,17 @@ function replyKey(commentId: number) {
return `reply-${commentId}`; return `reply-${commentId}`;
} }
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function handleCommentSortChange(event: Event) {
if (event.target instanceof HTMLSelectElement) {
activeCommentSort.value = event.target.value as CommentSort;
void loadComments(true);
}
}
function isCommentBusy(key: string) { function isCommentBusy(key: string) {
return commentBusyKey.value === key; return commentBusyKey.value === key;
} }
@@ -232,6 +252,19 @@ function canRestoreComment(comment: LifeComment) {
return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete'); return comment.deleted && currentUser.value?.id === comment.author?.id && can('life.comments.delete');
} }
function canLikeComment(comment: LifeComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function canRetryCommentModeration(comment: LifeComment) {
return (
!comment.deleted &&
comment.moderationStatus !== 'approved' &&
comment.moderationStatus !== 'reviewing' &&
(currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'))
);
}
function canSeeCommentModeration(comment: LifeComment) { function canSeeCommentModeration(comment: LifeComment) {
return moderationStatusVisible(comment.moderationStatus) && (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'));
} }
@@ -271,6 +304,10 @@ function reactionCountLabel(currentPost: LifePost, type: LifeReactionType) {
}); });
} }
function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
}
function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) { function openReactionUsersModal(postId: number, reactionType: LifeReactionType | null = null) {
reactionUsersModal.value = { postId, reactionType }; reactionUsersModal.value = { postId, reactionType };
} }
@@ -536,6 +573,9 @@ async function submitComment(currentPost: LifePost) {
currentPost.commentCount = commentsTotal.value; currentPost.commentCount = commentsTotal.value;
commentsLoaded.value = true; commentsLoaded.value = true;
commentBodies.value[currentPost.id] = ''; commentBodies.value[currentPost.id] = '';
if (activeCommentSort.value !== 'oldest') {
void loadComments(true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
} finally { } finally {
@@ -571,10 +611,14 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode
}); });
comment.replies.push(reply); comment.replies.push(reply);
comment.replyCount += 1;
commentsTotal.value += 1; commentsTotal.value += 1;
currentPost.commentCount = commentsTotal.value; currentPost.commentCount = commentsTotal.value;
commentsLoaded.value = true; commentsLoaded.value = true;
cancelReply(comment.id); cancelReply(comment.id);
if (activeCommentSort.value === 'most-replied') {
void loadComments(true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
} finally { } finally {
@@ -669,6 +713,45 @@ async function restoreComment(comment: LifeComment) {
} }
} }
async function retryCommentModeration(comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = await api.retryLifeCommentModeration(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function toggleCommentLike(comment: LifeComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = comment.myLiked ? await api.deleteLifeCommentLike(comment.id) : await api.setLifeCommentLike(comment.id);
replaceCommentInTree(comments.value, updated);
comments.value = [...comments.value];
if (activeCommentSort.value === 'most-liked') {
void loadComments(true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentLikeFailed'));
} finally {
commentBusyKey.value = '';
}
}
function commentAuthorName(comment: LifeComment) { function commentAuthorName(comment: LifeComment) {
return comment.author?.displayName ?? t('pages.life.byUnknown'); return comment.author?.displayName ?? t('pages.life.byUnknown');
} }
@@ -921,8 +1004,18 @@ onUnmounted(() => {
<section :id="`life-comments-${post.id}`" class="life-comments" :aria-label="t('pages.life.comments')"> <section :id="`life-comments-${post.id}`" class="life-comments" :aria-label="t('pages.life.comments')">
<div class="life-comments__header"> <div class="life-comments__header">
<h3>{{ t('pages.life.comments') }}</h3> <div>
<span>{{ commentsTotal }}</span> <h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentsTotal }}</span>
</div>
<label class="life-comments__sort">
<span>{{ t('pages.life.sort') }}</span>
<select :value="activeCommentSort" @change="handleCommentSortChange">
<option v-for="option in commentSortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div> </div>
<form v-if="canCommentOnPost" class="life-comment-form" @submit.prevent="submitComment(post)"> <form v-if="canCommentOnPost" class="life-comment-form" @submit.prevent="submitComment(post)">
@@ -995,6 +1088,19 @@ onUnmounted(() => {
</p> </p>
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions"> <div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="!comment.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || isCommentBusy(likeKey(comment.id))"
@click="toggleCommentLike(comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button <button
v-if="!comment.deleted && canCommentOnPost" v-if="!comment.deleted && canCommentOnPost"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -1026,8 +1132,24 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="retryCommentModeration(comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<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">
{{ commentErrors[replyKey(comment.id)] }} {{ commentErrors[replyKey(comment.id)] }}
</p> </p>
@@ -1092,7 +1214,20 @@ 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) || canRestoreComment(reply)" class="life-comment__actions"> <div v-if="!reply.deleted || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="!reply.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || isCommentBusy(likeKey(reply.id))"
@click="toggleCommentLike(reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button <button
v-if="canManageComment(reply)" 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"
@@ -1114,7 +1249,23 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="retryCommentModeration(reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<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)] }}
</p> </p>

View File

@@ -40,6 +40,7 @@ import {
setAuthToken, setAuthToken,
type AiModerationStatus, type AiModerationStatus,
type AuthUser, type AuthUser,
type CommentSort,
type GameVersion, type GameVersion,
type Language, type Language,
type LifeCategory, type LifeCategory,
@@ -93,6 +94,7 @@ const replyBodies = ref<Record<number, string>>({});
const replyTargetId = ref<number | null>(null); const replyTargetId = ref<number | null>(null);
const expandedComments = ref<Record<number, boolean>>({}); const expandedComments = ref<Record<number, boolean>>({});
const commentPages = ref<Record<number, LifeCommentPageState>>({}); const commentPages = ref<Record<number, LifeCommentPageState>>({});
const commentSorts = ref<Record<number, CommentSort>>({});
const commentBusyKey = ref(''); const commentBusyKey = ref('');
const commentErrors = ref<Record<string, string>>({}); const commentErrors = ref<Record<string, string>>({});
const reactionPickerPostId = ref<number | null>(null); const reactionPickerPostId = ref<number | null>(null);
@@ -134,6 +136,7 @@ function can(permissionKey: string) {
const canPost = computed(() => can('life.posts.create')); const canPost = computed(() => can('life.posts.create'));
const canComment = computed(() => can('life.comments.create')); const canComment = computed(() => can('life.comments.create'));
const canLikeComments = computed(() => can('life.comments.like'));
const canReact = computed(() => can('life.reactions.set')); const canReact = computed(() => can('life.reactions.set'));
const canRate = computed(() => can('life.ratings.set')); const canRate = computed(() => can('life.ratings.set'));
const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length)); const charactersLeft = computed(() => Math.max(0, bodyMaxLength - body.value.length));
@@ -183,6 +186,12 @@ 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 commentSortOptions = computed<Array<{ value: CommentSort; label: string }>>(() => [
{ value: 'oldest', label: t('pages.life.sortOldest') },
{ value: 'latest', label: t('pages.life.sortLatest') },
{ value: 'most-liked', label: t('pages.life.sortMostLiked') },
{ value: 'most-replied', label: t('pages.life.sortMostReplied') }
]);
const feedScopeOptions = computed<TabOption[]>(() => [ const feedScopeOptions = computed<TabOption[]>(() => [
{ value: 'all', label: t('pages.life.allFeed') }, { value: 'all', label: t('pages.life.allFeed') },
{ value: 'following', label: t('pages.life.followingFeed') } { value: 'following', label: t('pages.life.followingFeed') }
@@ -505,6 +514,28 @@ function replyKey(commentId: number) {
return `reply-${commentId}`; return `reply-${commentId}`;
} }
function likeKey(commentId: number) {
return `like-${commentId}`;
}
function commentSort(postId: number): CommentSort {
return commentSorts.value[postId] ?? 'oldest';
}
function setCommentSort(post: LifePost, sort: CommentSort) {
commentSorts.value = {
...commentSorts.value,
[post.id]: sort
};
void loadComments(post, true);
}
function handleCommentSortChange(post: LifePost, event: Event) {
if (event.target instanceof HTMLSelectElement) {
setCommentSort(post, event.target.value as CommentSort);
}
}
function initialCommentPage(post: LifePost): LifeCommentPageState { function initialCommentPage(post: LifePost): LifeCommentPageState {
return { return {
items: post.commentPreview, items: post.commentPreview,
@@ -768,7 +799,12 @@ async function loadComments(post: LifePost, reset = false) {
}); });
try { try {
const page = await api.lifeComments(post.id, { limit: lifeCommentPageSize, cursor, language: selectedFeedLanguageCode.value }); const page = await api.lifeComments(post.id, {
limit: lifeCommentPageSize,
cursor,
language: selectedFeedLanguageCode.value,
sort: commentSort(post.id)
});
const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items); const nextItems = reset || !existing.loaded ? page.items : mergeComments(existing.items, page.items);
setCommentPage(post.id, { setCommentPage(post.id, {
items: nextItems, items: nextItems,
@@ -858,6 +894,23 @@ function canUseReactions() {
return canReact.value && reactionBusyPostId.value === null; return canReact.value && reactionBusyPostId.value === null;
} }
function canLikeComment(comment: LifeComment) {
return canLikeComments.value && !comment.deleted && comment.moderationStatus === 'approved';
}
function canRetryCommentModeration(comment: LifeComment) {
return (
!comment.deleted &&
comment.moderationStatus !== 'approved' &&
comment.moderationStatus !== 'reviewing' &&
(currentUser.value?.id === comment.author?.id || can('life.comments.delete-any'))
);
}
function commentLikeLabel(comment: LifeComment) {
return comment.myLiked ? t('pages.life.unlikeComment') : t('pages.life.likeComment');
}
function canUseRatings(post: LifePost) { function canUseRatings(post: LifePost) {
return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true; return canRate.value && ratingBusyPostId.value === null && post.moderationStatus === 'approved' && post.category?.isRateable === true;
} }
@@ -1018,6 +1071,9 @@ async function submitComment(post: LifePost) {
})); }));
commentBodies.value[post.id] = ''; commentBodies.value[post.id] = '';
setCommentsExpanded(post.id, true); setCommentsExpanded(post.id, true);
if (commentSort(post.id) !== 'oldest') {
void loadComments(post, true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
} finally { } finally {
@@ -1044,12 +1100,16 @@ async function submitReply(post: LifePost, comment: LifeComment) {
const nextTotal = commentCount(post) + 1; const nextTotal = commentCount(post) + 1;
post.commentCount = nextTotal; post.commentCount = nextTotal;
comment.replies.push(reply); comment.replies.push(reply);
comment.replyCount += 1;
updateCommentPage(post, (page) => ({ updateCommentPage(post, (page) => ({
...page, ...page,
total: nextTotal total: nextTotal
})); }));
setCommentsExpanded(post.id, true); setCommentsExpanded(post.id, true);
cancelReply(comment.id); cancelReply(comment.id);
if (commentSort(post.id) === 'most-replied') {
void loadComments(post, true);
}
} catch (error) { } catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed')); setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
} finally { } finally {
@@ -1152,6 +1212,51 @@ async function restoreComment(post: LifePost, comment: LifeComment) {
} }
} }
async function retryCommentModeration(post: LifePost, comment: LifeComment) {
const key = replyKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = await api.retryLifeCommentModeration(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.moderationRetryFailed'));
} finally {
commentBusyKey.value = '';
}
}
async function toggleCommentLike(post: LifePost, comment: LifeComment) {
if (!canLikeComment(comment)) {
return;
}
const key = likeKey(comment.id);
commentBusyKey.value = key;
clearCommentError(key);
try {
const updated = comment.myLiked ? await api.deleteLifeCommentLike(comment.id) : await api.setLifeCommentLike(comment.id);
replaceCommentInTree(commentsForPost(post), updated);
updateCommentPage(post, (page) => ({
...page,
items: [...page.items]
}));
if (commentSort(post.id) === 'most-liked') {
void loadComments(post, true);
}
} catch (error) {
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentLikeFailed'));
} 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())) {
@@ -1635,8 +1740,18 @@ onUnmounted(() => {
:aria-label="t('pages.life.comments')" :aria-label="t('pages.life.comments')"
> >
<div class="life-comments__header"> <div class="life-comments__header">
<h3>{{ t('pages.life.comments') }}</h3> <div>
<span>{{ commentCount(post) }}</span> <h3>{{ t('pages.life.comments') }}</h3>
<span>{{ commentCount(post) }}</span>
</div>
<label class="life-comments__sort">
<span>{{ t('pages.life.sort') }}</span>
<select :value="commentSort(post.id)" @change="handleCommentSortChange(post, $event)">
<option v-for="option in commentSortOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</label>
</div> </div>
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)"> <form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
@@ -1709,6 +1824,19 @@ onUnmounted(() => {
</p> </p>
<div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions"> <div v-if="!comment.deleted || canRestoreComment(comment)" class="life-comment__actions">
<button
v-if="!comment.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(comment)"
:aria-pressed="comment.myLiked"
:disabled="!canLikeComment(comment) || isCommentBusy(likeKey(comment.id))"
@click="toggleCommentLike(post, comment)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ comment.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: comment.likeCount }) }}</span>
</button>
<button <button
v-if="!comment.deleted && canComment" v-if="!comment.deleted && canComment"
class="life-icon-button life-icon-button--flat" class="life-icon-button life-icon-button--flat"
@@ -1740,8 +1868,24 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(comment)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(comment.id))"
@click="retryCommentModeration(post, comment)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(comment.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(comment.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(comment.id)] }}
</p>
<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">
{{ commentErrors[replyKey(comment.id)] }} {{ commentErrors[replyKey(comment.id)] }}
</p> </p>
@@ -1806,7 +1950,20 @@ 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) || canRestoreComment(reply)" class="life-comment__actions"> <div v-if="!reply.deleted || canRestoreComment(reply)" class="life-comment__actions">
<button
v-if="!reply.deleted"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="commentLikeLabel(reply)"
:aria-pressed="reply.myLiked"
:disabled="!canLikeComment(reply) || isCommentBusy(likeKey(reply.id))"
@click="toggleCommentLike(post, reply)"
>
<Icon :icon="iconReactionLike" class="ui-icon" aria-hidden="true" />
<span class="life-comment__action-count">{{ reply.likeCount }}</span>
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.commentLikeCount', { count: reply.likeCount }) }}</span>
</button>
<button <button
v-if="canManageComment(reply)" 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"
@@ -1828,7 +1985,23 @@ onUnmounted(() => {
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span> <span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
</button> </button>
<button
v-if="canRetryCommentModeration(reply)"
class="life-icon-button life-icon-button--flat"
type="button"
:aria-label="isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry')"
:disabled="isCommentBusy(replyKey(reply.id))"
@click="retryCommentModeration(post, reply)"
>
<Icon :icon="iconWarning" class="ui-icon" aria-hidden="true" />
<span class="life-action-tooltip" role="tooltip">
{{ isCommentBusy(replyKey(reply.id)) ? t('pages.life.moderationRetrying') : t('pages.life.moderationRetry') }}
</span>
</button>
</div> </div>
<p v-if="commentErrors[likeKey(reply.id)]" class="life-form__error" role="alert">
{{ commentErrors[likeKey(reply.id)] }}
</p>
<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)] }}
</p> </p>

View File

@@ -114,7 +114,7 @@ watch(
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${recipe.item.displayId} ${recipe.name}`" :subtitle="recipeSubtitle"> <PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template> <template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
<template #actions> <template #actions>
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`"> <RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
@@ -145,7 +145,7 @@ watch(
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" /> <Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
</span> </span>
</RouterLink> </RouterLink>
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">#{{ recipe.item.displayId }} {{ recipe.item.name }}</RouterLink> <RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">{{ recipe.item.name }}</RouterLink>
</div> </div>
</section> </section>

View File

@@ -155,7 +155,7 @@ watch(itemQuery, loadItems);
<EntityCard <EntityCard
v-for="item in items" v-for="item in items"
:key="item.id" :key="item.id"
:title="`#${item.displayId} ${item.name}`" :title="item.name"
:subtitle="item.category.name" :subtitle="item.category.name"
:to="recipeTarget(item)" :to="recipeTarget(item)"
:icon="itemIcon(item)" :icon="itemIcon(item)"

View File

@@ -12,6 +12,7 @@ const sitemapPaths = [
'/event-items', '/event-items',
'/ancient-artifacts', '/ancient-artifacts',
'/recipes', '/recipes',
'/dish',
'/checklist', '/checklist',
'/life' '/life'
]; ];
@@ -36,7 +37,6 @@ const robotsDisallowPaths = [
'/recipes/new', '/recipes/new',
'/recipes/*/edit', '/recipes/*/edit',
'/automation', '/automation',
'/dish',
'/events', '/events',
'/actions', '/actions',
'/dream-island', '/dream-island',

View File

@@ -245,7 +245,7 @@ export const systemWordingMessages = {
}, },
eventItems: { eventItems: {
title: 'Event Items', title: 'Event Items',
description: 'Browse limited event items with their own Display IDs and shared item categories.' description: 'Browse limited event items with shared item categories and custom ordering.'
}, },
ancientArtifacts: { ancientArtifacts: {
title: 'Ancient Artifacts', title: 'Ancient Artifacts',
@@ -269,7 +269,7 @@ export const systemWordingMessages = {
}, },
dish: { dish: {
title: 'Dish', title: 'Dish',
description: 'Cooked dish and food discovery records are being prepared.' description: 'Browse cooked dishes by cookware, ingredients, flavor, and Mosslax effects.'
}, },
events: { events: {
title: 'Events', title: 'Events',
@@ -687,7 +687,6 @@ export const systemWordingMessages = {
loadingList: 'Loading item list', loadingList: 'Loading item list',
loadingDetail: 'Loading item detail', loadingDetail: 'Loading item detail',
loadingEdit: 'Loading item editor', loadingEdit: 'Loading item editor',
displayId: 'Display ID',
description: 'Description', description: 'Description',
category: 'Category', category: 'Category',
usage: 'Usage', usage: 'Usage',
@@ -724,14 +723,13 @@ export const systemWordingMessages = {
detailKicker: 'Ancient Artifact Detail', detailKicker: 'Ancient Artifact Detail',
detailSubtitle: 'Ancient Artifact detail', detailSubtitle: 'Ancient Artifact detail',
editKicker: 'Ancient Artifact Edit', editKicker: 'Ancient Artifact Edit',
editSubtitle: 'Maintain Ancient Artifact Display ID, image, description, category, tags, and translations.', editSubtitle: 'Maintain Ancient Artifact image, description, category, tags, and translations.',
newTitle: 'New Ancient Artifact', newTitle: 'New Ancient Artifact',
editTitle: 'Edit {name}', editTitle: 'Edit {name}',
fallbackName: 'Ancient Artifact', fallbackName: 'Ancient Artifact',
loadingList: 'Loading Ancient Artifact list', loadingList: 'Loading Ancient Artifact list',
loadingDetail: 'Loading Ancient Artifact detail', loadingDetail: 'Loading Ancient Artifact detail',
loadingEdit: 'Loading Ancient Artifact editor', loadingEdit: 'Loading Ancient Artifact editor',
displayId: 'Display ID',
description: 'Description', description: 'Description',
category: 'Category', category: 'Category',
tags: 'Tags', tags: 'Tags',
@@ -755,6 +753,30 @@ export const systemWordingMessages = {
materials: 'Materials', materials: 'Materials',
addMaterial: 'Add material' addMaterial: 'Add material'
}, },
dish: {
kicker: 'Dish',
title: 'Dish',
subtitle: 'Browse cooked dishes by category, cookware, ingredients, flavor, and Mosslax effects.',
loading: 'Loading Dish records',
category: 'Category',
categories: 'Categories',
dishes: 'Dishes',
cookware: 'Cookware',
effect: 'Effect',
totalMaterialQuantity: 'Total material count',
dishItem: 'Dish item',
flavor: 'Flavor',
mainMaterial: 'Main material',
secondaryMaterial: 'Secondary material',
secondaryMaterials: 'Secondary materials',
secondSecondaryMaterial: 'Second secondary material',
pokemonSkill: 'Pokemon speciality',
mosslaxEffect: 'Mosslax effect',
newCategory: 'New category',
editCategory: 'Edit category',
newDish: 'New dish',
editDish: 'Edit dish'
},
comingSoon: { comingSoon: {
status: 'In development', status: 'In development',
heading: 'This wiki section is being prepared.', heading: 'This wiki section is being prepared.',
@@ -872,6 +894,8 @@ export const systemWordingMessages = {
sortLatest: 'Latest', sortLatest: 'Latest',
sortOldest: 'Oldest', sortOldest: 'Oldest',
sortTopRated: 'Top rated', sortTopRated: 'Top rated',
sortMostLiked: 'Most liked',
sortMostReplied: 'Most replied',
categoryPlaceholder: 'Select category', categoryPlaceholder: 'Select category',
searchCategories: 'Search categories', searchCategories: 'Search categories',
search: 'Search Life', search: 'Search Life',
@@ -924,6 +948,10 @@ export const systemWordingMessages = {
deleteCommentConfirm: 'Delete this comment?', deleteCommentConfirm: 'Delete this comment?',
commentDeleted: 'Comment deleted', commentDeleted: 'Comment deleted',
restoreComment: 'Undo', restoreComment: 'Undo',
likeComment: 'Like comment',
unlikeComment: 'Unlike comment',
commentLikeCount: '{count} likes',
commentLikeFailed: 'Like failed',
commentRequired: 'Please enter a comment.', commentRequired: 'Please enter a comment.',
commentFailed: 'Comment failed', commentFailed: 'Comment failed',
replyFailed: 'Reply failed', replyFailed: 'Reply failed',
@@ -981,6 +1009,7 @@ export const systemWordingMessages = {
itemList: 'Item list', itemList: 'Item list',
ancientArtifactList: 'Ancient Artifact list', ancientArtifactList: 'Ancient Artifact list',
recipeList: 'Recipe list', recipeList: 'Recipe list',
dishList: 'Dish list',
habitatList: 'Habitat list', habitatList: 'Habitat list',
dataTools: 'Data tools', dataTools: 'Data tools',
dataToolRefresh: 'Refresh', dataToolRefresh: 'Refresh',
@@ -1095,7 +1124,8 @@ export const systemWordingMessages = {
acquisitionMethods: 'Acquisition methods', acquisitionMethods: 'Acquisition methods',
maps: 'Maps', maps: 'Maps',
lifeCategories: 'Life categories', lifeCategories: 'Life categories',
gameVersions: 'Game versions' gameVersions: 'Game versions',
dishFlavors: 'Dish flavors'
}, },
appearance: { appearance: {
time: 'Time', time: 'Time',
@@ -1174,6 +1204,15 @@ export const systemWordingMessages = {
moderationRetryFailed: 'Review retry failed', moderationRetryFailed: 'Review retry failed',
loading: 'Loading discussion', loading: 'Loading discussion',
loadMore: 'Load more comments', loadMore: 'Load more comments',
sort: 'Sort',
sortOldest: 'Oldest',
sortLatest: 'Latest',
sortMostLiked: 'Most liked',
sortMostReplied: 'Most replied',
likeComment: 'Like comment',
unlikeComment: 'Unlike comment',
commentLikeCount: '{count} likes',
commentLikeFailed: 'Like failed',
empty: 'No discussion yet', empty: 'No discussion yet',
emptyHint: 'Start a new discussion now.', emptyHint: 'Start a new discussion now.',
loginPrompt: 'Log in with a verified email to comment.', loginPrompt: 'Log in with a verified email to comment.',
@@ -1270,11 +1309,9 @@ export const systemWordingMessages = {
environmentRequired: 'Ideal Habitat is required', environmentRequired: 'Ideal Habitat is required',
skillNoDrop: 'This speciality cannot have a drop item', skillNoDrop: 'This speciality cannot have a drop item',
habitatNameRequired: 'Habitat name is required', habitatNameRequired: 'Habitat name is required',
itemDisplayIdRequired: 'Item Display ID is required',
usageRequired: 'Usage is required', usageRequired: 'Usage is required',
itemNameRequired: 'Item name is required', itemNameRequired: 'Item name is required',
categoryRequired: 'Category is required', categoryRequired: 'Category is required',
artifactDisplayIdRequired: 'Ancient Artifact Display ID is required',
artifactNameRequired: 'Ancient Artifact name is required', artifactNameRequired: 'Ancient Artifact name is required',
recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free', recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free',
itemRequired: 'Item is required', itemRequired: 'Item is required',
@@ -1559,7 +1596,7 @@ export const systemWordingMessages = {
}, },
eventItems: { eventItems: {
title: 'Event Items', title: 'Event Items',
description: '浏览限时活动物品,并维护独立的 Display ID 与共享分类。' description: '浏览限时活动物品、共享分类与自定义排序。'
}, },
ancientArtifacts: { ancientArtifacts: {
title: 'Ancient Artifacts', title: 'Ancient Artifacts',
@@ -1583,7 +1620,7 @@ export const systemWordingMessages = {
}, },
dish: { dish: {
title: 'Dish', title: 'Dish',
description: '料理和食物发现记录正在准备中。' description: '按厨具、材料、口味和苔藓卡比兽效果浏览料理。'
}, },
events: { events: {
title: 'Events', title: 'Events',
@@ -1981,7 +2018,6 @@ export const systemWordingMessages = {
loadingList: '正在加载列表', loadingList: '正在加载列表',
loadingDetail: '正在加载物品详情', loadingDetail: '正在加载物品详情',
loadingEdit: '正在加载物品编辑内容', loadingEdit: '正在加载物品编辑内容',
displayId: 'Display ID',
description: '介绍', description: '介绍',
category: '分类', category: '分类',
usage: '用途', usage: '用途',
@@ -2018,14 +2054,13 @@ export const systemWordingMessages = {
detailKicker: 'Ancient Artifact Detail', detailKicker: 'Ancient Artifact Detail',
detailSubtitle: 'Ancient Artifact 详情', detailSubtitle: 'Ancient Artifact 详情',
editKicker: 'Ancient Artifact Edit', editKicker: 'Ancient Artifact Edit',
editSubtitle: '维护 Ancient Artifact Display ID、图片、介绍、分类、标签和翻译。', editSubtitle: '维护 Ancient Artifact 图片、介绍、分类、标签和翻译。',
newTitle: '新增 Ancient Artifact', newTitle: '新增 Ancient Artifact',
editTitle: '编辑 {name}', editTitle: '编辑 {name}',
fallbackName: 'Ancient Artifact', fallbackName: 'Ancient Artifact',
loadingList: '正在加载 Ancient Artifact 列表', loadingList: '正在加载 Ancient Artifact 列表',
loadingDetail: '正在加载 Ancient Artifact 详情', loadingDetail: '正在加载 Ancient Artifact 详情',
loadingEdit: '正在加载 Ancient Artifact 编辑内容', loadingEdit: '正在加载 Ancient Artifact 编辑内容',
displayId: 'Display ID',
description: '介绍', description: '介绍',
category: '分类', category: '分类',
tags: '标签', tags: '标签',
@@ -2049,6 +2084,30 @@ export const systemWordingMessages = {
materials: '需要材料', materials: '需要材料',
addMaterial: '添加材料' addMaterial: '添加材料'
}, },
dish: {
kicker: 'Dish',
title: '料理',
subtitle: '按分类、厨具、材料、口味和苔藓卡比兽效果浏览料理。',
loading: '正在加载料理记录',
category: '分类',
categories: '分类',
dishes: '菜肴',
cookware: '厨具',
effect: '吃后效果',
totalMaterialQuantity: '总数所需材料数量',
dishItem: '菜肴物品',
flavor: '口味',
mainMaterial: '主材料',
secondaryMaterial: '副材料',
secondaryMaterials: '副材料',
secondSecondaryMaterial: '第二副材料',
pokemonSkill: 'Pokemon 特长',
mosslaxEffect: 'Mosslax 效果',
newCategory: '新增分类',
editCategory: '编辑分类',
newDish: '新增菜肴',
editDish: '编辑菜肴'
},
comingSoon: { comingSoon: {
status: '正在开发中', status: '正在开发中',
heading: '这个 Wiki 分区正在准备中。', heading: '这个 Wiki 分区正在准备中。',
@@ -2166,6 +2225,8 @@ export const systemWordingMessages = {
sortLatest: '最新', sortLatest: '最新',
sortOldest: '最早', sortOldest: '最早',
sortTopRated: '评分最高', sortTopRated: '评分最高',
sortMostLiked: '点赞最多',
sortMostReplied: '回复最多',
categoryPlaceholder: '选择 Category', categoryPlaceholder: '选择 Category',
searchCategories: '搜索 Category', searchCategories: '搜索 Category',
search: '搜索动态', search: '搜索动态',
@@ -2218,6 +2279,10 @@ export const systemWordingMessages = {
deleteCommentConfirm: '确认删除这条评论?', deleteCommentConfirm: '确认删除这条评论?',
commentDeleted: '评论已删除', commentDeleted: '评论已删除',
restoreComment: '撤销', restoreComment: '撤销',
likeComment: '点赞评论',
unlikeComment: '取消点赞评论',
commentLikeCount: '{count} 个赞',
commentLikeFailed: '点赞失败',
commentRequired: '请输入评论内容。', commentRequired: '请输入评论内容。',
commentFailed: '评论失败', commentFailed: '评论失败',
replyFailed: '回复失败', replyFailed: '回复失败',
@@ -2275,6 +2340,7 @@ export const systemWordingMessages = {
itemList: '物品列表', itemList: '物品列表',
ancientArtifactList: 'Ancient Artifact 列表', ancientArtifactList: 'Ancient Artifact 列表',
recipeList: '材料单列表', recipeList: '材料单列表',
dishList: '料理列表',
habitatList: '栖息地列表', habitatList: '栖息地列表',
dataTools: '数据工具', dataTools: '数据工具',
dataToolRefresh: '刷新', dataToolRefresh: '刷新',
@@ -2389,7 +2455,8 @@ export const systemWordingMessages = {
acquisitionMethods: '入手方式', acquisitionMethods: '入手方式',
maps: '地图', maps: '地图',
lifeCategories: 'Life Categories', lifeCategories: 'Life Categories',
gameVersions: '游戏版本' gameVersions: '游戏版本',
dishFlavors: '料理味道'
}, },
appearance: { appearance: {
time: '时段', time: '时段',
@@ -2468,6 +2535,15 @@ export const systemWordingMessages = {
moderationRetryFailed: '重新审核失败', moderationRetryFailed: '重新审核失败',
loading: '正在加载讨论', loading: '正在加载讨论',
loadMore: '加载更多评论', loadMore: '加载更多评论',
sort: '排序',
sortOldest: '最早',
sortLatest: '最新',
sortMostLiked: '点赞最多',
sortMostReplied: '回复最多',
likeComment: '点赞评论',
unlikeComment: '取消点赞评论',
commentLikeCount: '{count} 个赞',
commentLikeFailed: '点赞失败',
empty: '暂无讨论', empty: '暂无讨论',
emptyHint: '现在发起新的讨论。', emptyHint: '现在发起新的讨论。',
loginPrompt: '使用已验证邮箱登录后即可评论。', loginPrompt: '使用已验证邮箱登录后即可评论。',
@@ -2562,14 +2638,12 @@ export const systemWordingMessages = {
heightNonNegative: '身高必须是不小于 0 的数字', heightNonNegative: '身高必须是不小于 0 的数字',
weightNonNegative: '体重必须是不小于 0 的数字', weightNonNegative: '体重必须是不小于 0 的数字',
environmentRequired: '请选择喜欢的环境', environmentRequired: '请选择喜欢的环境',
skillNoDrop: '这个特长不能设置掉落物', skillNoDrop: '这个特长不能设置掉落物',
habitatNameRequired: '请输入栖息地名称', habitatNameRequired: '请输入栖息地名称',
itemDisplayIdRequired: '请输入物品 Display ID', usageRequired: '请选择用途',
usageRequired: '请选择用途', itemNameRequired: '请输入物品名称',
itemNameRequired: '请输入物品名称', categoryRequired: '请选择分类',
categoryRequired: '请选择分类', artifactNameRequired: '请输入 Ancient Artifact 名称',
artifactDisplayIdRequired: '请输入 Ancient Artifact Display ID',
artifactNameRequired: '请输入 Ancient Artifact 名称',
recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单', recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单',
itemRequired: '请选择物品', itemRequired: '请选择物品',
recipeFreeItem: '这个物品已标记为无材料单', recipeFreeItem: '这个物品已标记为无材料单',