Compare commits
4 Commits
504849c14a
...
cd0f8868c3
| Author | SHA1 | Date | |
|---|---|---|---|
| cd0f8868c3 | |||
| 28f4e6032c | |||
| 2220d5d595 | |||
| 2ff2519647 |
88
DESIGN.md
88
DESIGN.md
@@ -65,11 +65,16 @@
|
||||
- 每日 CheckList Task
|
||||
- Life Category
|
||||
- Game Version
|
||||
- Dish Category
|
||||
- Dish Flavor
|
||||
- Dish
|
||||
- 支持翻译的字段:
|
||||
- `name`
|
||||
- `title`
|
||||
- `details`:Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
|
||||
- `genus`:仅 Pokemon Genus 使用
|
||||
- `effect`:Dish Category 的吃后效果
|
||||
- `mosslaxEffect`:Dish 给 Mosslax 吃之后的效果
|
||||
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
||||
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
||||
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。
|
||||
@@ -377,8 +382,9 @@
|
||||
- 讨论回复只支持一层回复,不做无限嵌套。
|
||||
- 评论作者拥有 `discussions.comments.delete` 权限时可以删除自己的评论;拥有 `discussions.comments.delete-any` 权限的用户可以删除其他用户评论;删除后正文不再展示,已有回复保留在原位置。
|
||||
- 被删除实体的讨论会随实体删除一并清理。
|
||||
- 讨论按创建时间正序展示。
|
||||
- 讨论列表按顶层评论分页读取,支持 `limit` / `cursor`;每页顶层评论携带其一层回复,响应包含 `items`、`nextCursor`、`hasMore`、`total`。
|
||||
- 讨论列表支持 `sort`:`oldest` 默认按创建时间正序;`latest` 按创建时间倒序;`most-liked` 按点赞数倒序;`most-replied` 按直接回复数倒序;同分时使用创建时间和 ID 保持稳定排序。排序只作用于顶层评论,回复始终按创建时间正序展示。
|
||||
- 已注册并完成邮箱验证且拥有 `discussions.comments.like` 权限的用户可以点赞或取消点赞审核通过且未删除的实体讨论评论;每个用户对每条评论最多 1 个 Like。
|
||||
- 实体讨论评论和回复必须进入 AI 审核;未审核通过的评论不向普通访客公开。
|
||||
- 作者本人和拥有 `discussions.comments.delete-any` 权限的管理用户可看到相关评论的审核状态,并可触发重新审核。
|
||||
- 审核状态包括:`unreviewed`、`reviewing`、`approved`、`rejected`、`failed`;前端面向用户展示为未审核、审核中、审核通过、审核不通过、审核失败。
|
||||
@@ -390,6 +396,7 @@
|
||||
- 讨论列表支持按语言区读取;`language=all` 或不传语言参数时读取全部已公开语言区,传入具体语言 code 时只读取对应语言区。
|
||||
- 讨论内容是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||
- API 对外只返回评论作者的 `id` 和 `displayName`。
|
||||
- API 对外返回讨论评论的 `likeCount`、`replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
|
||||
- API 可返回作者本人或管理用户处理评论所需的审核状态、语言区和 `rejected` / `failed` 原因详情;不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈、`deleted_at`、`deleted_by_user_id` 等内部字段。
|
||||
|
||||
## AI 审核
|
||||
@@ -586,7 +593,6 @@ Pokemon 详情页展示:
|
||||
|
||||
物品可配置:
|
||||
|
||||
- Display ID:用于物品和 Event Items 各自列表内展示与排序;`display_id` 与 `is_event_item` 组合唯一
|
||||
- 名称
|
||||
- 介绍
|
||||
- 是否为 Event Item:`is_event_item`
|
||||
@@ -631,9 +637,10 @@ Items 与 Event Items 使用相同数据模型:
|
||||
- 按分类展示为标签页
|
||||
- 按用途筛选
|
||||
- 按标签筛选
|
||||
- 按 Display ID 和自定义排序展示
|
||||
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、`#Display ID 名称` 和分类;不展示标签、入手方式或编辑元信息。
|
||||
- 有用途的物品在卡片左上角以斜 Ribbon 展示用途名称。
|
||||
- 按自定义排序展示
|
||||
- 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。
|
||||
- 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。
|
||||
- 物品列表不展示标签、入手方式或编辑元信息。
|
||||
- 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。
|
||||
|
||||
物品详情页展示:
|
||||
@@ -641,7 +648,6 @@ Items 与 Event Items 使用相同数据模型:
|
||||
- 基本信息
|
||||
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
||||
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
||||
- Display ID
|
||||
- 介绍
|
||||
- 分类
|
||||
- 用途
|
||||
@@ -660,7 +666,6 @@ Items 与 Event Items 使用相同数据模型:
|
||||
|
||||
Ancient Artifacts 是独立 Wiki 内容类型,可配置:
|
||||
|
||||
- Display ID:用于展示与排序
|
||||
- 名称
|
||||
- 介绍
|
||||
- 图片:使用 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 详情页展示:
|
||||
|
||||
- Display ID
|
||||
- 名称
|
||||
- 图片;未配置图片时展示默认 Ancient Artifact 标记
|
||||
- 介绍
|
||||
@@ -712,8 +718,8 @@ Ancient Artifacts 详情页展示:
|
||||
|
||||
- 独立于物品列表展示
|
||||
- 按结果物品分类展示
|
||||
- 按结果物品 Display ID 和自定义排序展示
|
||||
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、`#Display ID 名称` 和分类;不展示编辑元信息。
|
||||
- 按自定义排序展示
|
||||
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、名称和分类;不展示编辑元信息。
|
||||
- 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。
|
||||
- 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 详情页默认展示该 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 Reaction 的其他类型通过右键 / context menu 或可见展开按钮打开 Popup 选择;再次选择当前 Reaction 会取消,选择其他 Reaction 会替换原 Reaction。
|
||||
- 用户可在 Life Post 的 Reaction 汇总处打开 Modal 查看公开 Reaction 用户列表;列表支持按 Reaction 类型筛选并分页加载。
|
||||
@@ -850,7 +895,7 @@ Life Post 可配置:
|
||||
- 新增或更新 Life Post 后先进入不可公开状态,AI 审核通过后才出现在普通公开 Feed。
|
||||
- Life Comment 和回复审核通过且未删除后才出现在普通公开评论列表、评论数量和评论预览中;已删除评论只在作者自己的可见评论列表、评论数量和评论预览中保留,以便作者 Undo。
|
||||
- 审核失败不等于审核通过;失败内容保持不可公开,用户可重新审核。
|
||||
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核。
|
||||
- `reviewing` 表示审核正在进行中,前端不展示重新审核入口;只有 `unreviewed`、`rejected` 和 `failed` 这类非进行中且未通过状态可触发重新审核,API 也必须拒绝对 `reviewing` 或 `approved` 评论重新审核。
|
||||
- Life Post 是用户生成内容,正文按作者输入展示,不进入 `entity_translations`。
|
||||
|
||||
API 暴露边界:
|
||||
@@ -861,11 +906,12 @@ API 暴露边界:
|
||||
- Life Post Rating 只返回 `ratingAverage`、`ratingCount` 和当前用户自己的 `myRating`;不返回其他用户的评分明细。
|
||||
- Life Post 可返回面向用户展示所需的审核状态、审核语言区、审核原因详情和是否可重审;审核原因详情仅用于 `rejected` / `failed`,不返回内部错误、AI prompt、模型响应、错误堆栈或 retry 细节。
|
||||
- Life Comment 作者信息只返回 `id` 和 `displayName`。
|
||||
- Life Comment 只返回 `likeCount`、`replyCount` 和当前用户自己的 `myLiked`;不返回点赞用户列表、邮箱、角色、权限或内部审计。
|
||||
- Life Post 列表和详情中的 Life Reaction 只返回按类型汇总的数量和当前用户自己的 Reaction,不内嵌其他用户明细。
|
||||
- Life Reaction 用户列表 API 只返回公开用户摘要 `id`、`displayName`、`reactionType` 和 `reactedAt`;不返回邮箱、角色、权限、token/hash、内部审计或其他用户隐私字段。
|
||||
- Life Post 列表 API 返回分页结果:`items`、`nextCursor`、`hasMore`;`cursor` 是不透明分页令牌。每个 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` 原因详情。
|
||||
- API 不返回邮箱、token/hash、内部调试字段、AI prompt、模型原始响应、内部审核错误、错误堆栈或不必要的审计 payload。
|
||||
- API 不返回 Life Post 的 `deleted_at`、`deleted_by_user_id` 等内部软删除字段。
|
||||
@@ -876,7 +922,6 @@ API 暴露边界:
|
||||
以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力:
|
||||
|
||||
- Automation:未来用于分享自动化基地(亦称工厂)创建方案、材料产出、所需 Pokemon、生产顺序和共同喜好物品。
|
||||
- Dish
|
||||
- Events
|
||||
- Actions:游戏内快捷动作,例如挥手、跳舞等。
|
||||
- Dream Island
|
||||
@@ -1009,18 +1054,19 @@ API 暴露边界:
|
||||
- `GET /api/ancient-artifacts/:id`
|
||||
- `GET /api/recipes`
|
||||
- `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/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。
|
||||
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。
|
||||
- `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/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||
- `PUT /api/users/:id/follow`:需要 `users.follow`;Follow 指定用户并返回更新后的公开 Profile。
|
||||
- `DELETE /api/users/:id/follow`:需要 `users.follow`;Unfollow 指定用户并返回更新后的公开 Profile。
|
||||
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`、`ancient-artifacts`。
|
||||
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;支持 `sort` 为 `oldest`、`latest`、`most-liked` 或 `most-replied`;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`、`ancient-artifacts`。
|
||||
|
||||
认证 API:
|
||||
|
||||
@@ -1075,11 +1121,17 @@ API 暴露边界:
|
||||
- `DELETE /api/life-comments/:id`
|
||||
- `POST /api/life-comments/:id/restore`
|
||||
- `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` 权限。
|
||||
- `POST /api/discussions/:entityType/:entityId/comments`
|
||||
- `POST /api/discussions/:entityType/:entityId/comments/:commentId/replies`
|
||||
- `DELETE /api/discussions/comments/:id`
|
||||
- `POST /api/discussions/comments/:id/moderation/retry`
|
||||
- 实体讨论评论的点赞和取消点赞需要 `discussions.comments.like` 权限。
|
||||
- `PUT /api/discussions/comments/:id/like`
|
||||
- `DELETE /api/discussions/comments/:id/like`
|
||||
- Life Reaction 的设置、替换和取消。
|
||||
- `PUT /api/life-posts/:id/reaction`
|
||||
- `DELETE /api/life-posts/:id/reaction`
|
||||
|
||||
@@ -35,12 +35,15 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
||||
'habitats',
|
||||
'daily-checklist-items',
|
||||
'life-tags',
|
||||
'game-versions'
|
||||
'game-versions',
|
||||
'dish-categories',
|
||||
'dish-flavors',
|
||||
'dishes'
|
||||
)
|
||||
),
|
||||
entity_id integer NOT NULL,
|
||||
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,
|
||||
PRIMARY KEY (entity_type, entity_id, locale, field_name)
|
||||
);
|
||||
@@ -68,10 +71,21 @@ ALTER TABLE entity_translations
|
||||
'habitats',
|
||||
'daily-checklist-items',
|
||||
'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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
@@ -291,6 +305,10 @@ VALUES
|
||||
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
|
||||
('recipes.delete', 'Delete recipes', 'Delete 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.update', 'Update own Life posts', 'Edit 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.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.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.ratings.set', 'Set Life ratings', 'Set and remove Life star ratings.', 'Life', true),
|
||||
('users.follow', 'Follow users', 'Follow and unfollow public user profiles.', 'Users', true),
|
||||
('discussions.comments.create', 'Create discussion comments', 'Create entity discussion comments and replies.', 'Discussions', true),
|
||||
('discussions.comments.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;
|
||||
|
||||
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.delete',
|
||||
'recipes.order',
|
||||
'dish.create',
|
||||
'dish.update',
|
||||
'dish.delete',
|
||||
'dish.order',
|
||||
'life.posts.create',
|
||||
'life.posts.update',
|
||||
'life.posts.delete',
|
||||
@@ -391,12 +415,14 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'life.comments.create',
|
||||
'life.comments.delete',
|
||||
'life.comments.delete-any',
|
||||
'life.comments.like',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete',
|
||||
'discussions.comments.delete-any'
|
||||
'discussions.comments.delete-any',
|
||||
'discussions.comments.like'
|
||||
])
|
||||
WHERE r.key = 'admin'
|
||||
AND NOT EXISTS (
|
||||
@@ -455,16 +481,21 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'recipes.create',
|
||||
'recipes.update',
|
||||
'recipes.order',
|
||||
'dish.create',
|
||||
'dish.update',
|
||||
'dish.order',
|
||||
'life.posts.create',
|
||||
'life.posts.update',
|
||||
'life.posts.delete',
|
||||
'life.comments.create',
|
||||
'life.comments.delete',
|
||||
'life.comments.like',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete'
|
||||
'discussions.comments.delete',
|
||||
'discussions.comments.like'
|
||||
])
|
||||
WHERE r.key = 'editor'
|
||||
AND NOT EXISTS (
|
||||
@@ -499,6 +530,29 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
WHERE r.key = 'editor'
|
||||
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)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
@@ -508,11 +562,13 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'life.posts.delete',
|
||||
'life.comments.create',
|
||||
'life.comments.delete',
|
||||
'life.comments.like',
|
||||
'life.reactions.set',
|
||||
'life.ratings.set',
|
||||
'users.follow',
|
||||
'discussions.comments.create',
|
||||
'discussions.comments.delete'
|
||||
'discussions.comments.delete',
|
||||
'discussions.comments.like'
|
||||
])
|
||||
WHERE r.key = 'member'
|
||||
AND NOT EXISTS (
|
||||
@@ -529,6 +585,20 @@ JOIN permissions p ON p.key = 'life.ratings.set'
|
||||
WHERE r.key IN ('admin', 'editor', 'member')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
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)
|
||||
SELECT r.id, p.id
|
||||
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
|
||||
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 (
|
||||
post_id integer NOT NULL REFERENCES life_posts(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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
display_id integer NOT NULL CHECK (display_id > 0),
|
||||
name text NOT NULL UNIQUE,
|
||||
details text NOT NULL DEFAULT '',
|
||||
category_key text NOT NULL DEFAULT 'other',
|
||||
@@ -928,7 +1010,6 @@ CREATE TABLE IF NOT EXISTS items (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ancient_artifacts (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
display_id integer NOT NULL UNIQUE CHECK (display_id > 0),
|
||||
name text NOT NULL UNIQUE,
|
||||
details text NOT NULL DEFAULT '',
|
||||
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)
|
||||
);
|
||||
|
||||
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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
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;
|
||||
|
||||
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 category_key text,
|
||||
ADD COLUMN IF NOT EXISTS usage_key text;
|
||||
@@ -1053,10 +1239,6 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
UPDATE items
|
||||
SET display_id = id
|
||||
WHERE display_id IS NULL;
|
||||
|
||||
UPDATE items i
|
||||
SET category_key = CASE lower(trim(c.name))
|
||||
WHEN 'furniture' THEN 'furniture'
|
||||
@@ -1114,7 +1296,6 @@ WHERE usage_key IS NOT NULL
|
||||
AND usage_key NOT IN ('decoration', 'relaxation', 'toy', 'road');
|
||||
|
||||
ALTER TABLE items
|
||||
ALTER COLUMN display_id SET NOT NULL,
|
||||
ALTER COLUMN category_key SET NOT NULL,
|
||||
ALTER COLUMN category_key SET DEFAULT 'other';
|
||||
|
||||
@@ -1124,7 +1305,6 @@ ALTER TABLE items
|
||||
DROP CONSTRAINT IF EXISTS items_usage_key_check;
|
||||
|
||||
ALTER TABLE items
|
||||
ADD CONSTRAINT items_display_id_positive CHECK (display_id > 0),
|
||||
ADD CONSTRAINT items_category_key_check CHECK (category_key IN (
|
||||
'furniture',
|
||||
'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'));
|
||||
|
||||
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 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);
|
||||
@@ -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 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 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_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 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 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
|
||||
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
|
||||
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,8 @@ import {
|
||||
createAncientArtifact,
|
||||
createConfig,
|
||||
createDailyChecklistItem,
|
||||
createDish,
|
||||
createDishCategory,
|
||||
createEntityDiscussionComment,
|
||||
createEntityDiscussionReply,
|
||||
createHabitat,
|
||||
@@ -51,11 +53,15 @@ import {
|
||||
deleteConfig,
|
||||
deleteAncientArtifact,
|
||||
deleteDailyChecklistItem,
|
||||
deleteDish,
|
||||
deleteDishCategory,
|
||||
deleteEntityDiscussionComment,
|
||||
deleteHabitat,
|
||||
deleteItem,
|
||||
deleteLanguage,
|
||||
deleteEntityDiscussionCommentLike,
|
||||
deleteLifeComment,
|
||||
deleteLifeCommentLike,
|
||||
deleteLifePost,
|
||||
deleteLifePostRating,
|
||||
deleteLifePostReaction,
|
||||
@@ -69,6 +75,7 @@ import {
|
||||
getAncientArtifact,
|
||||
getHabitat,
|
||||
getItem,
|
||||
listDish,
|
||||
getLifePost,
|
||||
getOptions,
|
||||
getPokemon,
|
||||
@@ -97,6 +104,8 @@ import {
|
||||
reorderConfig,
|
||||
reorderAncientArtifacts,
|
||||
reorderDailyChecklistItems,
|
||||
reorderDishCategories,
|
||||
reorderDishes,
|
||||
reorderHabitats,
|
||||
reorderItems,
|
||||
reorderLanguages,
|
||||
@@ -108,9 +117,13 @@ import {
|
||||
restoreLifeComment,
|
||||
setLifePostRating,
|
||||
setLifePostReaction,
|
||||
setEntityDiscussionCommentLike,
|
||||
setLifeCommentLike,
|
||||
updateConfig,
|
||||
updateAncientArtifact,
|
||||
updateDailyChecklistItem,
|
||||
updateDish,
|
||||
updateDishCategory,
|
||||
updateHabitat,
|
||||
updateItem,
|
||||
updateLanguage,
|
||||
@@ -1470,6 +1483,26 @@ app.post('/api/life-comments/:id/restore', async (request, reply) => {
|
||||
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) => {
|
||||
const user = await requireAnyPermissionWithRateLimits(
|
||||
request,
|
||||
@@ -1580,6 +1613,28 @@ app.post('/api/discussions/comments/:id/moderation/retry', async (request, reply
|
||||
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) =>
|
||||
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);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite');
|
||||
return user
|
||||
|
||||
@@ -97,7 +97,7 @@ const navItems = computed<NavItem[]>(() => {
|
||||
},
|
||||
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||
{ 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.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
|
||||
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },
|
||||
|
||||
@@ -16,7 +16,6 @@ const changeLabelKeys: Record<string, string> = {
|
||||
标题: 'pages.checklist.task',
|
||||
'Pokemon ID': 'pages.pokemon.id',
|
||||
'Pokopia ID': 'pages.pokemon.id',
|
||||
'Display ID': 'pages.items.displayId',
|
||||
'Event item': 'common.eventItem',
|
||||
'Event Pokemon': 'pages.pokemon.eventItem',
|
||||
'Event Habitat': 'pages.habitats.eventItem',
|
||||
@@ -118,12 +117,17 @@ function changeValue(value: string): string {
|
||||
return values[value] ?? value;
|
||||
}
|
||||
|
||||
function visibleChanges(entry: EditHistoryEntry) {
|
||||
return entry.changes.filter((change) => change.label !== 'Display ID');
|
||||
}
|
||||
|
||||
function historySummary(entry: EditHistoryEntry): string {
|
||||
if (!entry.changes.length) {
|
||||
const changes = visibleChanges(entry);
|
||||
if (!changes.length) {
|
||||
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 {
|
||||
@@ -175,8 +179,8 @@ function formatDateTime(value: string): string {
|
||||
</summary>
|
||||
|
||||
<div class="edit-history-entry__content">
|
||||
<dl v-if="entry.changes.length" class="edit-change-list">
|
||||
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`">
|
||||
<dl v-if="visibleChanges(entry).length" class="edit-change-list">
|
||||
<div v-for="change in visibleChanges(entry)" :key="`${change.label}-${change.before}-${change.after}`">
|
||||
<dt>{{ changeLabel(change.label) }}</dt>
|
||||
<dd>
|
||||
<span class="edit-change-list__label">{{ t('history.before') }}</span>
|
||||
|
||||
@@ -11,18 +11,28 @@ defineProps<{
|
||||
marker?: string;
|
||||
image?: { src: string; alt: string };
|
||||
ribbon?: string;
|
||||
compactTooltip?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
||||
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span>
|
||||
<RouterLink
|
||||
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 }">
|
||||
<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" />
|
||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||
<span v-else>{{ marker }}</span>
|
||||
</span>
|
||||
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
|
||||
<div class="entity-card__content">
|
||||
<span class="entity-card__title">{{ title }}</span>
|
||||
<slot name="after-title"></slot>
|
||||
@@ -31,14 +41,17 @@ defineProps<{
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<article v-else class="entity-card">
|
||||
<span v-if="ribbon" class="entity-card__ribbon">{{ ribbon }}</span>
|
||||
<article v-else class="entity-card" :class="{ 'entity-card--collection-compact': compactTooltip }">
|
||||
<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 }">
|
||||
<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" />
|
||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||
<span v-else>{{ marker }}</span>
|
||||
</span>
|
||||
<span v-if="compactTooltip" class="entity-card__tooltip" role="tooltip">{{ title }}</span>
|
||||
<div class="entity-card__content">
|
||||
<span class="entity-card__title">{{ title }}</span>
|
||||
<slot name="after-title"></slot>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import StatusBadge from './StatusBadge.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 {
|
||||
api,
|
||||
getAuthToken,
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
type DiscussionEntityType,
|
||||
type EntityDiscussionComment,
|
||||
type Language,
|
||||
@@ -41,7 +42,9 @@ const formError = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const commentInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const activeLanguageCode = ref('all');
|
||||
const activeSort = ref<CommentSort>('oldest');
|
||||
const moderationBusyId = ref<number | null>(null);
|
||||
const likeBusyId = ref<number | null>(null);
|
||||
const commentMaxLength = 1000;
|
||||
const discussionPageSize = 20;
|
||||
const allLanguageValue = 'all';
|
||||
@@ -56,12 +59,19 @@ function can(permissionKey: string) {
|
||||
}
|
||||
|
||||
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 selectedLanguageCode = computed(() => (activeLanguageCode.value === allLanguageValue ? undefined : activeLanguageCode.value));
|
||||
const languageTabs = computed<TabOption[]>(() => [
|
||||
{ value: allLanguageValue, label: t('discussion.allLanguages') },
|
||||
...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() {
|
||||
authReady.value = false;
|
||||
@@ -119,7 +129,8 @@ async function loadDiscussion(reset = true) {
|
||||
const page = await api.entityDiscussion(props.entityType, props.entityId, {
|
||||
limit: discussionPageSize,
|
||||
cursor: reset ? null : nextCursor.value,
|
||||
language: selectedLanguageCode.value
|
||||
language: selectedLanguageCode.value,
|
||||
sort: activeSort.value
|
||||
});
|
||||
if (nextRequestId === requestId) {
|
||||
comments.value = reset ? page.items : mergeComments(comments.value, page.items);
|
||||
@@ -151,6 +162,17 @@ function commentKey(commentId: number) {
|
||||
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) {
|
||||
return replyBodies.value[commentId] ?? '';
|
||||
}
|
||||
@@ -181,6 +203,14 @@ function canRetryModeration(comment: EntityDiscussionComment) {
|
||||
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) {
|
||||
return (
|
||||
!comment.deleted &&
|
||||
@@ -267,6 +297,9 @@ async function submitComment() {
|
||||
comments.value = [...comments.value, comment];
|
||||
commentTotal.value += 1;
|
||||
body.value = '';
|
||||
if (activeSort.value !== 'oldest') {
|
||||
void loadDiscussion();
|
||||
}
|
||||
} catch (error) {
|
||||
formError.value = error instanceof Error && error.message ? error.message : t('discussion.commentFailed');
|
||||
} finally {
|
||||
@@ -291,8 +324,12 @@ async function submitReply(comment: EntityDiscussionComment) {
|
||||
languageCode: selectedLanguageCode.value ?? comment.moderationLanguageCode
|
||||
});
|
||||
comment.replies.push(reply);
|
||||
comment.replyCount += 1;
|
||||
commentTotal.value += 1;
|
||||
cancelReply(comment.id);
|
||||
if (activeSort.value === 'most-replied') {
|
||||
void loadDiscussion();
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('discussion.replyFailed'));
|
||||
} 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(
|
||||
items: EntityDiscussionComment[],
|
||||
commentId: number,
|
||||
@@ -455,6 +535,14 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Skeleton variant="box" height="112px" />
|
||||
@@ -528,6 +616,18 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<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
|
||||
v-if="canComment"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -563,6 +663,9 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</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">
|
||||
{{ commentErrors[commentKey(comment.id)] }}
|
||||
</p>
|
||||
@@ -624,7 +727,19 @@ onUnmounted(() => {
|
||||
<strong>{{ t('discussion.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</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
|
||||
v-if="canRetryModeration(reply)"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -639,6 +754,7 @@ onUnmounted(() => {
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageComment(reply)"
|
||||
class="life-icon-button life-icon-button--flat life-icon-button--danger"
|
||||
type="button"
|
||||
:aria-label="t('discussion.deleteComment')"
|
||||
@@ -648,6 +764,9 @@ onUnmounted(() => {
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('discussion.deleteComment') }}</span>
|
||||
</button>
|
||||
</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">
|
||||
{{ commentErrors[commentKey(reply.id)] }}
|
||||
</p>
|
||||
|
||||
@@ -13,6 +13,7 @@ import RecipeDetail from '../views/RecipeDetail.vue';
|
||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifePostDetail from '../views/LifePostDetail.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import DishView from '../views/DishView.vue';
|
||||
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
|
||||
import LegalView from '../views/LegalView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
@@ -267,9 +268,8 @@ export const router = createRouter({
|
||||
{
|
||||
path: '/dish',
|
||||
name: 'dish',
|
||||
component: ComingSoonView,
|
||||
props: { page: 'dish' },
|
||||
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dish.title', descriptionKey: 'pages.comingSoon.sections.dish.subtitle', noindex: true }) }
|
||||
component: DishView,
|
||||
meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
|
||||
},
|
||||
{
|
||||
path: '/events',
|
||||
|
||||
@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
|
||||
const authTokenKey = 'pokopia_auth_token';
|
||||
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 interface Language {
|
||||
@@ -246,7 +246,6 @@ export interface HabitatUsage {
|
||||
}
|
||||
|
||||
export interface RecipeResultItem extends NamedEntity {
|
||||
displayId: number;
|
||||
image?: EntityImage | null;
|
||||
category?: NamedEntity;
|
||||
usage?: NamedEntity | null;
|
||||
@@ -254,7 +253,6 @@ export interface RecipeResultItem extends NamedEntity {
|
||||
|
||||
export interface Item extends EditInfo {
|
||||
id: number;
|
||||
displayId: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
details: string;
|
||||
@@ -276,7 +274,6 @@ export interface Item extends EditInfo {
|
||||
|
||||
export interface AncientArtifact extends EditInfo {
|
||||
id: number;
|
||||
displayId: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
details: string;
|
||||
@@ -311,6 +308,36 @@ export interface Recipe extends EditInfo {
|
||||
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 {
|
||||
id: number;
|
||||
title: string;
|
||||
@@ -421,8 +448,11 @@ export interface CommentPageParams {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
language?: string;
|
||||
sort?: CommentSort;
|
||||
}
|
||||
|
||||
export type CommentSort = 'oldest' | 'latest' | 'most-liked' | 'most-replied';
|
||||
|
||||
export interface LifeComment {
|
||||
id: number;
|
||||
postId: number;
|
||||
@@ -435,6 +465,9 @@ export interface LifeComment {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
myLiked: boolean;
|
||||
replies: LifeComment[];
|
||||
}
|
||||
|
||||
@@ -546,6 +579,7 @@ export interface Options {
|
||||
maps: NamedEntity[];
|
||||
lifeCategories: LifeCategory[];
|
||||
gameVersions: GameVersion[];
|
||||
dishFlavors: NamedEntity[];
|
||||
}
|
||||
|
||||
export interface AuthUser {
|
||||
@@ -705,7 +739,8 @@ export type ConfigType =
|
||||
| 'acquisition-methods'
|
||||
| 'maps'
|
||||
| 'life-tags'
|
||||
| 'game-versions';
|
||||
| 'game-versions'
|
||||
| 'dish-flavors';
|
||||
|
||||
export interface PokemonPayload {
|
||||
dataId?: number | null;
|
||||
@@ -752,7 +787,6 @@ export interface PokemonImageOptionsResult {
|
||||
}
|
||||
|
||||
export interface ItemPayload {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
translations?: TranslationMap;
|
||||
@@ -769,7 +803,6 @@ export interface ItemPayload {
|
||||
}
|
||||
|
||||
export interface AncientArtifactPayload {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
translations?: TranslationMap;
|
||||
@@ -784,6 +817,25 @@ export interface RecipePayload {
|
||||
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 {
|
||||
name: string;
|
||||
translations?: TranslationMap;
|
||||
@@ -831,6 +883,9 @@ export interface EntityDiscussionComment {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
author: UserSummary | null;
|
||||
likeCount: number;
|
||||
replyCount: number;
|
||||
myLiked: boolean;
|
||||
replies: EntityDiscussionComment[];
|
||||
}
|
||||
|
||||
@@ -1229,7 +1284,8 @@ export const api = {
|
||||
`/api/life-posts/${postId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
language: params.language,
|
||||
sort: params.sort
|
||||
})}`
|
||||
),
|
||||
createLifeCommentReply: (postId: string | number, commentId: string | number, payload: LifeCommentPayload) =>
|
||||
@@ -1237,13 +1293,16 @@ export const api = {
|
||||
retryLifeCommentModeration: (id: string | number) =>
|
||||
sendJson<LifeComment>(`/api/life-comments/${id}/moderation/retry`, '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}`),
|
||||
entityDiscussion: (entityType: DiscussionEntityType, entityId: string | number, params: CommentPageParams = {}) =>
|
||||
getJson<EntityDiscussionCommentsPage>(
|
||||
`/api/discussions/${entityType}/${entityId}/comments${buildQuery({
|
||||
cursor: params.cursor ?? undefined,
|
||||
limit: params.limit,
|
||||
language: params.language
|
||||
language: params.language,
|
||||
sort: params.sort
|
||||
})}`
|
||||
),
|
||||
createEntityDiscussionComment: (
|
||||
@@ -1259,6 +1318,10 @@ export const api = {
|
||||
) => sendJson<EntityDiscussionComment>(`/api/discussions/${entityType}/${entityId}/comments/${commentId}/replies`, 'POST', payload),
|
||||
retryEntityDiscussionModeration: (id: string | number) =>
|
||||
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}`),
|
||||
uploadImage: (
|
||||
entityType: ImageUploadEntityType,
|
||||
@@ -1342,5 +1405,16 @@ export const api = {
|
||||
updateRecipe: (id: string | number, payload: RecipePayload) =>
|
||||
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
|
||||
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 })
|
||||
};
|
||||
|
||||
@@ -2343,9 +2343,17 @@ button:disabled,
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.entity-card__ribbon {
|
||||
.entity-card__ribbon-clip {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--radius-card) - 2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.entity-card__ribbon {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: -38px;
|
||||
width: 132px;
|
||||
@@ -2362,7 +2370,6 @@ button:disabled,
|
||||
font-size: 0.72rem;
|
||||
font-weight: 950;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -2438,6 +2445,86 @@ button:disabled,
|
||||
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 {
|
||||
min-height: 36px;
|
||||
max-width: 100%;
|
||||
@@ -3262,7 +3349,8 @@ button:disabled,
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.life-comments__header span {
|
||||
.life-comments__header > span,
|
||||
.life-comments__header > div > span {
|
||||
min-width: 32px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -3274,6 +3362,25 @@ button:disabled,
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
@@ -3386,6 +3493,13 @@ button:disabled,
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.life-comment__action-count {
|
||||
min-width: 1ch;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.life-comments__empty {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -4529,6 +4643,26 @@ button:disabled,
|
||||
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-form,
|
||||
.entity-discussion-list {
|
||||
@@ -7712,6 +7846,31 @@ button:disabled,
|
||||
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,
|
||||
.catalog-card-grid .entity-card__mark {
|
||||
width: 56px;
|
||||
@@ -8642,6 +8801,11 @@ button:disabled,
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.collections-card-grid .entity-card--collection-compact {
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.entity-card__mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -8653,6 +8817,16 @@ button:disabled,
|
||||
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,
|
||||
.catalog-card-grid .pokeball-mark {
|
||||
--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) {
|
||||
.brand-lockup--topbar > span {
|
||||
display: none;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
iconCancel,
|
||||
iconChecklist,
|
||||
iconDelete,
|
||||
iconDish,
|
||||
iconEdit,
|
||||
iconHabitat,
|
||||
iconItem,
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
type DataToolsBundle,
|
||||
type DataToolsSummary,
|
||||
type DailyChecklistItem,
|
||||
type Dish,
|
||||
type DishCategory,
|
||||
type GameVersion,
|
||||
type Habitat,
|
||||
type Item,
|
||||
@@ -80,6 +83,7 @@ type AdminTab =
|
||||
| 'items'
|
||||
| 'ancientArtifacts'
|
||||
| 'recipes'
|
||||
| 'dish'
|
||||
| 'habitats';
|
||||
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
||||
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
|
||||
@@ -131,6 +135,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
items: iconItem,
|
||||
ancientArtifacts: iconArtifact,
|
||||
recipes: iconRecipe,
|
||||
dish: iconDish,
|
||||
habitats: iconHabitat
|
||||
};
|
||||
|
||||
@@ -156,6 +161,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
||||
permission: ['ancient-artifacts.order', 'ancient-artifacts.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: '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: 'maps', label: t('config.maps') },
|
||||
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
|
||||
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }
|
||||
{ key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true },
|
||||
{ key: 'dish-flavors', label: t('config.dishFlavors') }
|
||||
]);
|
||||
|
||||
const activeTab = ref<AdminTab>('config');
|
||||
@@ -212,6 +219,10 @@ const pokemonRows = ref<Pokemon[]>([]);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const ancientArtifactRows = ref<AncientArtifact[]>([]);
|
||||
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 wordingRows = ref<SystemWording[]>([]);
|
||||
const aiModerationSettings = ref<AiModerationSettings | null>(null);
|
||||
@@ -231,6 +242,25 @@ const configForm = ref({
|
||||
changeLog: ''
|
||||
});
|
||||
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 wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
||||
const aiModerationForm = ref({
|
||||
@@ -262,6 +292,8 @@ const permissionForm = ref({ id: 0, key: '', name: '', description: '', category
|
||||
const editingLanguageCode = ref('');
|
||||
const configModalOpen = ref(false);
|
||||
const checklistModalOpen = ref(false);
|
||||
const dishCategoryModalOpen = ref(false);
|
||||
const dishModalOpen = ref(false);
|
||||
const languageModalOpen = ref(false);
|
||||
const wordingModalOpen = 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 })
|
||||
);
|
||||
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 wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
||||
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 pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
|
||||
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 ancientArtifactLabel = (item: AncientArtifact) => `#${item.displayId} ${item.name}`;
|
||||
const ancientArtifactLabel = (item: AncientArtifact) => item.name;
|
||||
const recipeKey = (item: Recipe) => item.id;
|
||||
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 habitatLabel = (item: Habitat) => item.name;
|
||||
|
||||
@@ -525,6 +568,31 @@ function resetChecklistForm() {
|
||||
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() {
|
||||
languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 };
|
||||
editingLanguageCode.value = '';
|
||||
@@ -621,6 +689,53 @@ function editChecklistItem(item: DailyChecklistItem) {
|
||||
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() {
|
||||
resetLanguageForm();
|
||||
languageModalOpen.value = true;
|
||||
@@ -786,6 +901,21 @@ function previewRecipeOrder(rows: Recipe[]) {
|
||||
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[]) {
|
||||
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[]) {
|
||||
habitatRows.value = nextRows;
|
||||
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() {
|
||||
await run(async () => {
|
||||
const payload = {
|
||||
@@ -978,6 +1185,18 @@ async function loadRecipes() {
|
||||
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() {
|
||||
habitatRows.value = await api.habitats();
|
||||
}
|
||||
@@ -1153,6 +1372,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
||||
if (activeTab.value === 'items') await loadItems();
|
||||
if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts();
|
||||
if (activeTab.value === 'recipes') await loadRecipes();
|
||||
if (activeTab.value === 'dish') await loadDishAdmin();
|
||||
if (activeTab.value === 'habitats') await loadHabitats();
|
||||
} finally {
|
||||
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) {
|
||||
await run(async () => {
|
||||
await api.deleteHabitat(id);
|
||||
@@ -2020,7 +2260,7 @@ onMounted(() => {
|
||||
@reorder="persistItemOrder"
|
||||
>
|
||||
<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">
|
||||
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
@@ -2048,7 +2288,7 @@ onMounted(() => {
|
||||
@reorder="persistAncientArtifactOrder"
|
||||
>
|
||||
<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">
|
||||
<button v-if="can('ancient-artifacts.delete')" type="button" :disabled="busy" @click="removeAncientArtifact(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
@@ -2088,6 +2328,84 @@ onMounted(() => {
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</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">
|
||||
<h2>{{ t('pages.admin.habitatList') }}</h2>
|
||||
<ReorderableList
|
||||
@@ -2324,6 +2642,131 @@ onMounted(() => {
|
||||
</template>
|
||||
</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">
|
||||
<form id="admin-config-form" class="modal-edit-form" @submit.prevent="saveConfig">
|
||||
<div class="field">
|
||||
|
||||
@@ -96,7 +96,7 @@ watch(
|
||||
</section>
|
||||
</section>
|
||||
<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 #actions>
|
||||
<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">
|
||||
<DetailSection :title="t('common.details')">
|
||||
<dl class="entity-profile-facts">
|
||||
<div>
|
||||
<dt>{{ t('pages.ancientArtifacts.displayId') }}</dt>
|
||||
<dd>#{{ artifact.displayId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.ancientArtifacts.category') }}</dt>
|
||||
<dd>{{ artifact.category.name }}</dd>
|
||||
|
||||
@@ -36,7 +36,6 @@ const busy = ref(false);
|
||||
const message = ref('');
|
||||
const creatingSelect = ref('');
|
||||
const artifactForm = ref({
|
||||
displayId: 1,
|
||||
name: '',
|
||||
details: '',
|
||||
translations: {} as TranslationMap,
|
||||
@@ -98,7 +97,6 @@ async function loadEditor() {
|
||||
if (isEditing.value) {
|
||||
const artifact = await api.ancientArtifactDetail(routeId.value);
|
||||
artifactForm.value = {
|
||||
displayId: artifact.displayId,
|
||||
name: artifact.baseName ?? artifact.name,
|
||||
details: artifact.baseDetails ?? artifact.details,
|
||||
translations: artifact.translations ?? {},
|
||||
@@ -142,7 +140,6 @@ async function saveArtifact() {
|
||||
|
||||
try {
|
||||
const payload: AncientArtifactPayload = {
|
||||
displayId: artifactForm.value.displayId,
|
||||
name: artifactNameForSave(),
|
||||
details: artifactForm.value.details,
|
||||
translations: artifactForm.value.translations,
|
||||
@@ -190,11 +187,6 @@ onMounted(() => {
|
||||
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
|
||||
id-prefix="artifact-details"
|
||||
v-model:base-value="artifactForm.details"
|
||||
|
||||
@@ -113,8 +113,12 @@ watch(artifactQuery, loadArtifacts);
|
||||
</div>
|
||||
</FilterPanel>
|
||||
|
||||
<div v-if="loading" class="entity-grid catalog-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">
|
||||
<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 entity-card--collection-compact"
|
||||
>
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="128px" height="24px" />
|
||||
@@ -122,15 +126,16 @@ watch(artifactQuery, loadArtifacts);
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="entity-grid catalog-card-grid">
|
||||
<div v-else class="entity-grid catalog-card-grid collections-card-grid">
|
||||
<EntityCard
|
||||
v-for="artifact in artifacts"
|
||||
:key="artifact.id"
|
||||
:title="`#${artifact.displayId} ${artifact.name}`"
|
||||
:title="artifact.name"
|
||||
:subtitle="artifact.category.name"
|
||||
:to="`/ancient-artifacts/${artifact.id}`"
|
||||
:icon="iconArtifact"
|
||||
:image="artifactCardImage(artifact)"
|
||||
compact-tooltip
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
609
frontend/src/views/DishView.vue
Normal file
609
frontend/src/views/DishView.vue
Normal 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>
|
||||
@@ -149,7 +149,7 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
<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 #actions>
|
||||
<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">
|
||||
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||
<dl class="entity-profile-facts">
|
||||
<div>
|
||||
<dt>{{ t('pages.items.displayId') }}</dt>
|
||||
<dd>#{{ item.displayId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.items.category') }}</dt>
|
||||
<dd>{{ item.category.name }}</dd>
|
||||
|
||||
@@ -36,7 +36,6 @@ const busy = ref(false);
|
||||
const message = ref('');
|
||||
const creatingSelect = ref('');
|
||||
const itemForm = ref({
|
||||
displayId: 1,
|
||||
name: '',
|
||||
details: '',
|
||||
translations: {} as TranslationMap,
|
||||
@@ -117,7 +116,6 @@ async function loadEditor() {
|
||||
if (isEditing.value) {
|
||||
const item = await api.itemDetail(routeId.value);
|
||||
itemForm.value = {
|
||||
displayId: item.displayId,
|
||||
name: item.baseName ?? item.name,
|
||||
details: item.baseDetails ?? item.details,
|
||||
translations: item.translations ?? {},
|
||||
@@ -173,7 +171,6 @@ async function saveItem() {
|
||||
|
||||
try {
|
||||
const payload: ItemPayload = {
|
||||
displayId: itemForm.value.displayId,
|
||||
name: itemNameForSave(),
|
||||
details: itemForm.value.details,
|
||||
translations: itemForm.value.translations,
|
||||
@@ -226,11 +223,6 @@ onMounted(() => {
|
||||
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
|
||||
id-prefix="item-details"
|
||||
v-model:base-value="itemForm.details"
|
||||
|
||||
@@ -132,8 +132,12 @@ watch(itemQuery, loadItems);
|
||||
</div>
|
||||
</FilterPanel>
|
||||
|
||||
<div v-if="loading" class="entity-grid catalog-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">
|
||||
<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 entity-card--collection-compact"
|
||||
>
|
||||
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||
<div class="entity-card__content">
|
||||
<Skeleton width="128px" height="24px" />
|
||||
@@ -141,16 +145,17 @@ watch(itemQuery, loadItems);
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="entity-grid catalog-card-grid">
|
||||
<div v-else class="entity-grid catalog-card-grid collections-card-grid">
|
||||
<EntityCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="`#${item.displayId} ${item.name}`"
|
||||
:title="item.name"
|
||||
:subtitle="item.category.name"
|
||||
:to="`/items/${item.id}`"
|
||||
:icon="iconItem"
|
||||
:image="itemCardImage(item)"
|
||||
:ribbon="item.usage?.name"
|
||||
compact-tooltip
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
type LifeComment,
|
||||
type LifePost,
|
||||
type LifeReactionType,
|
||||
@@ -53,6 +54,7 @@ const commentsLoading = ref(false);
|
||||
const commentsLoadingMore = ref(false);
|
||||
const commentsLoaded = ref(false);
|
||||
const commentsError = ref('');
|
||||
const activeCommentSort = ref<CommentSort>('oldest');
|
||||
const commentBodies = ref<Record<number, string>>({});
|
||||
const replyBodies = ref<Record<number, string>>({});
|
||||
const replyTargetId = ref<number | null>(null);
|
||||
@@ -82,9 +84,16 @@ function can(permissionKey: string) {
|
||||
}
|
||||
|
||||
const canComment = computed(() => can('life.comments.create'));
|
||||
const canLikeComments = computed(() => can('life.comments.like'));
|
||||
const canReact = computed(() => can('life.reactions.set'));
|
||||
const canRate = computed(() => can('life.ratings.set'));
|
||||
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() {
|
||||
const value = route.params.id;
|
||||
@@ -185,7 +194,7 @@ async function loadComments(reset = false) {
|
||||
}
|
||||
|
||||
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);
|
||||
commentsNextCursor.value = page.nextCursor;
|
||||
commentsHasMore.value = page.hasMore;
|
||||
@@ -208,6 +217,17 @@ function replyKey(commentId: number) {
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
reactionUsersModal.value = { postId, reactionType };
|
||||
}
|
||||
@@ -536,6 +573,9 @@ async function submitComment(currentPost: LifePost) {
|
||||
currentPost.commentCount = commentsTotal.value;
|
||||
commentsLoaded.value = true;
|
||||
commentBodies.value[currentPost.id] = '';
|
||||
if (activeCommentSort.value !== 'oldest') {
|
||||
void loadComments(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
||||
} finally {
|
||||
@@ -571,10 +611,14 @@ async function submitReply(currentPost: LifePost, comment: LifeComment) {
|
||||
languageCode: comment.moderationLanguageCode ?? currentPost.moderationLanguageCode
|
||||
});
|
||||
comment.replies.push(reply);
|
||||
comment.replyCount += 1;
|
||||
commentsTotal.value += 1;
|
||||
currentPost.commentCount = commentsTotal.value;
|
||||
commentsLoaded.value = true;
|
||||
cancelReply(comment.id);
|
||||
if (activeCommentSort.value === 'most-replied') {
|
||||
void loadComments(true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
||||
} 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) {
|
||||
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')">
|
||||
<div class="life-comments__header">
|
||||
<h3>{{ t('pages.life.comments') }}</h3>
|
||||
<span>{{ commentsTotal }}</span>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<form v-if="canCommentOnPost" class="life-comment-form" @submit.prevent="submitComment(post)">
|
||||
@@ -995,6 +1088,19 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<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
|
||||
v-if="!comment.deleted && canCommentOnPost"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -1026,8 +1132,24 @@ onUnmounted(() => {
|
||||
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
{{ commentErrors[replyKey(comment.id)] }}
|
||||
</p>
|
||||
@@ -1092,7 +1214,20 @@ onUnmounted(() => {
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</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
|
||||
v-if="canManageComment(reply)"
|
||||
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" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
<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">
|
||||
{{ commentErrors[replyKey(reply.id)] }}
|
||||
</p>
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
setAuthToken,
|
||||
type AiModerationStatus,
|
||||
type AuthUser,
|
||||
type CommentSort,
|
||||
type GameVersion,
|
||||
type Language,
|
||||
type LifeCategory,
|
||||
@@ -93,6 +94,7 @@ const replyBodies = ref<Record<number, string>>({});
|
||||
const replyTargetId = ref<number | null>(null);
|
||||
const expandedComments = ref<Record<number, boolean>>({});
|
||||
const commentPages = ref<Record<number, LifeCommentPageState>>({});
|
||||
const commentSorts = ref<Record<number, CommentSort>>({});
|
||||
const commentBusyKey = ref('');
|
||||
const commentErrors = ref<Record<string, string>>({});
|
||||
const reactionPickerPostId = ref<number | null>(null);
|
||||
@@ -134,6 +136,7 @@ function can(permissionKey: string) {
|
||||
|
||||
const canPost = computed(() => can('life.posts.create'));
|
||||
const canComment = computed(() => can('life.comments.create'));
|
||||
const canLikeComments = computed(() => can('life.comments.like'));
|
||||
const canReact = computed(() => can('life.reactions.set'));
|
||||
const canRate = computed(() => can('life.ratings.set'));
|
||||
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: '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[]>(() => [
|
||||
{ value: 'all', label: t('pages.life.allFeed') },
|
||||
{ value: 'following', label: t('pages.life.followingFeed') }
|
||||
@@ -505,6 +514,28 @@ function replyKey(commentId: number) {
|
||||
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 {
|
||||
return {
|
||||
items: post.commentPreview,
|
||||
@@ -768,7 +799,12 @@ async function loadComments(post: LifePost, reset = false) {
|
||||
});
|
||||
|
||||
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);
|
||||
setCommentPage(post.id, {
|
||||
items: nextItems,
|
||||
@@ -858,6 +894,23 @@ function canUseReactions() {
|
||||
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) {
|
||||
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] = '';
|
||||
setCommentsExpanded(post.id, true);
|
||||
if (commentSort(post.id) !== 'oldest') {
|
||||
void loadComments(post, true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.commentFailed'));
|
||||
} finally {
|
||||
@@ -1044,12 +1100,16 @@ async function submitReply(post: LifePost, comment: LifeComment) {
|
||||
const nextTotal = commentCount(post) + 1;
|
||||
post.commentCount = nextTotal;
|
||||
comment.replies.push(reply);
|
||||
comment.replyCount += 1;
|
||||
updateCommentPage(post, (page) => ({
|
||||
...page,
|
||||
total: nextTotal
|
||||
}));
|
||||
setCommentsExpanded(post.id, true);
|
||||
cancelReply(comment.id);
|
||||
if (commentSort(post.id) === 'most-replied') {
|
||||
void loadComments(post, true);
|
||||
}
|
||||
} catch (error) {
|
||||
setCommentError(key, error instanceof Error && error.message ? error.message : t('pages.life.replyFailed'));
|
||||
} 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) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
@@ -1635,8 +1740,18 @@ onUnmounted(() => {
|
||||
:aria-label="t('pages.life.comments')"
|
||||
>
|
||||
<div class="life-comments__header">
|
||||
<h3>{{ t('pages.life.comments') }}</h3>
|
||||
<span>{{ commentCount(post) }}</span>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<form v-if="canComment" class="life-comment-form" @submit.prevent="submitComment(post)">
|
||||
@@ -1709,6 +1824,19 @@ onUnmounted(() => {
|
||||
</p>
|
||||
|
||||
<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
|
||||
v-if="!comment.deleted && canComment"
|
||||
class="life-icon-button life-icon-button--flat"
|
||||
@@ -1740,8 +1868,24 @@ onUnmounted(() => {
|
||||
<Icon :icon="iconUndo" class="ui-icon" aria-hidden="true" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
|
||||
<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">
|
||||
{{ commentErrors[replyKey(comment.id)] }}
|
||||
</p>
|
||||
@@ -1806,7 +1950,20 @@ onUnmounted(() => {
|
||||
<strong>{{ t('pages.life.moderationReason') }}</strong>
|
||||
<span>{{ reply.moderationReason }}</span>
|
||||
</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
|
||||
v-if="canManageComment(reply)"
|
||||
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" />
|
||||
<span class="life-action-tooltip" role="tooltip">{{ t('pages.life.restoreComment') }}</span>
|
||||
</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>
|
||||
<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">
|
||||
{{ commentErrors[replyKey(reply.id)] }}
|
||||
</p>
|
||||
|
||||
@@ -114,7 +114,7 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
<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 #actions>
|
||||
<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" />
|
||||
</span>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ watch(itemQuery, loadItems);
|
||||
<EntityCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="`#${item.displayId} ${item.name}`"
|
||||
:title="item.name"
|
||||
:subtitle="item.category.name"
|
||||
:to="recipeTarget(item)"
|
||||
:icon="itemIcon(item)"
|
||||
|
||||
@@ -12,6 +12,7 @@ const sitemapPaths = [
|
||||
'/event-items',
|
||||
'/ancient-artifacts',
|
||||
'/recipes',
|
||||
'/dish',
|
||||
'/checklist',
|
||||
'/life'
|
||||
];
|
||||
@@ -36,7 +37,6 @@ const robotsDisallowPaths = [
|
||||
'/recipes/new',
|
||||
'/recipes/*/edit',
|
||||
'/automation',
|
||||
'/dish',
|
||||
'/events',
|
||||
'/actions',
|
||||
'/dream-island',
|
||||
|
||||
@@ -245,7 +245,7 @@ export const systemWordingMessages = {
|
||||
},
|
||||
eventItems: {
|
||||
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: {
|
||||
title: 'Ancient Artifacts',
|
||||
@@ -269,7 +269,7 @@ export const systemWordingMessages = {
|
||||
},
|
||||
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: {
|
||||
title: 'Events',
|
||||
@@ -687,7 +687,6 @@ export const systemWordingMessages = {
|
||||
loadingList: 'Loading item list',
|
||||
loadingDetail: 'Loading item detail',
|
||||
loadingEdit: 'Loading item editor',
|
||||
displayId: 'Display ID',
|
||||
description: 'Description',
|
||||
category: 'Category',
|
||||
usage: 'Usage',
|
||||
@@ -724,14 +723,13 @@ export const systemWordingMessages = {
|
||||
detailKicker: 'Ancient Artifact Detail',
|
||||
detailSubtitle: 'Ancient Artifact detail',
|
||||
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',
|
||||
editTitle: 'Edit {name}',
|
||||
fallbackName: 'Ancient Artifact',
|
||||
loadingList: 'Loading Ancient Artifact list',
|
||||
loadingDetail: 'Loading Ancient Artifact detail',
|
||||
loadingEdit: 'Loading Ancient Artifact editor',
|
||||
displayId: 'Display ID',
|
||||
description: 'Description',
|
||||
category: 'Category',
|
||||
tags: 'Tags',
|
||||
@@ -755,6 +753,30 @@ export const systemWordingMessages = {
|
||||
materials: 'Materials',
|
||||
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: {
|
||||
status: 'In development',
|
||||
heading: 'This wiki section is being prepared.',
|
||||
@@ -872,6 +894,8 @@ export const systemWordingMessages = {
|
||||
sortLatest: 'Latest',
|
||||
sortOldest: 'Oldest',
|
||||
sortTopRated: 'Top rated',
|
||||
sortMostLiked: 'Most liked',
|
||||
sortMostReplied: 'Most replied',
|
||||
categoryPlaceholder: 'Select category',
|
||||
searchCategories: 'Search categories',
|
||||
search: 'Search Life',
|
||||
@@ -924,6 +948,10 @@ export const systemWordingMessages = {
|
||||
deleteCommentConfirm: 'Delete this comment?',
|
||||
commentDeleted: 'Comment deleted',
|
||||
restoreComment: 'Undo',
|
||||
likeComment: 'Like comment',
|
||||
unlikeComment: 'Unlike comment',
|
||||
commentLikeCount: '{count} likes',
|
||||
commentLikeFailed: 'Like failed',
|
||||
commentRequired: 'Please enter a comment.',
|
||||
commentFailed: 'Comment failed',
|
||||
replyFailed: 'Reply failed',
|
||||
@@ -981,6 +1009,7 @@ export const systemWordingMessages = {
|
||||
itemList: 'Item list',
|
||||
ancientArtifactList: 'Ancient Artifact list',
|
||||
recipeList: 'Recipe list',
|
||||
dishList: 'Dish list',
|
||||
habitatList: 'Habitat list',
|
||||
dataTools: 'Data tools',
|
||||
dataToolRefresh: 'Refresh',
|
||||
@@ -1095,7 +1124,8 @@ export const systemWordingMessages = {
|
||||
acquisitionMethods: 'Acquisition methods',
|
||||
maps: 'Maps',
|
||||
lifeCategories: 'Life categories',
|
||||
gameVersions: 'Game versions'
|
||||
gameVersions: 'Game versions',
|
||||
dishFlavors: 'Dish flavors'
|
||||
},
|
||||
appearance: {
|
||||
time: 'Time',
|
||||
@@ -1174,6 +1204,15 @@ export const systemWordingMessages = {
|
||||
moderationRetryFailed: 'Review retry failed',
|
||||
loading: 'Loading discussion',
|
||||
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',
|
||||
emptyHint: 'Start a new discussion now.',
|
||||
loginPrompt: 'Log in with a verified email to comment.',
|
||||
@@ -1270,11 +1309,9 @@ export const systemWordingMessages = {
|
||||
environmentRequired: 'Ideal Habitat is required',
|
||||
skillNoDrop: 'This speciality cannot have a drop item',
|
||||
habitatNameRequired: 'Habitat name is required',
|
||||
itemDisplayIdRequired: 'Item Display ID is required',
|
||||
usageRequired: 'Usage is required',
|
||||
itemNameRequired: 'Item name is required',
|
||||
categoryRequired: 'Category is required',
|
||||
artifactDisplayIdRequired: 'Ancient Artifact Display ID is required',
|
||||
artifactNameRequired: 'Ancient Artifact name is required',
|
||||
recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free',
|
||||
itemRequired: 'Item is required',
|
||||
@@ -1559,7 +1596,7 @@ export const systemWordingMessages = {
|
||||
},
|
||||
eventItems: {
|
||||
title: 'Event Items',
|
||||
description: '浏览限时活动物品,并维护独立的 Display ID 与共享分类。'
|
||||
description: '浏览限时活动物品、共享分类与自定义排序。'
|
||||
},
|
||||
ancientArtifacts: {
|
||||
title: 'Ancient Artifacts',
|
||||
@@ -1583,7 +1620,7 @@ export const systemWordingMessages = {
|
||||
},
|
||||
dish: {
|
||||
title: 'Dish',
|
||||
description: '料理和食物发现记录正在准备中。'
|
||||
description: '按厨具、材料、口味和苔藓卡比兽效果浏览料理。'
|
||||
},
|
||||
events: {
|
||||
title: 'Events',
|
||||
@@ -1981,7 +2018,6 @@ export const systemWordingMessages = {
|
||||
loadingList: '正在加载列表',
|
||||
loadingDetail: '正在加载物品详情',
|
||||
loadingEdit: '正在加载物品编辑内容',
|
||||
displayId: 'Display ID',
|
||||
description: '介绍',
|
||||
category: '分类',
|
||||
usage: '用途',
|
||||
@@ -2018,14 +2054,13 @@ export const systemWordingMessages = {
|
||||
detailKicker: 'Ancient Artifact Detail',
|
||||
detailSubtitle: 'Ancient Artifact 详情',
|
||||
editKicker: 'Ancient Artifact Edit',
|
||||
editSubtitle: '维护 Ancient Artifact Display ID、图片、介绍、分类、标签和翻译。',
|
||||
editSubtitle: '维护 Ancient Artifact 图片、介绍、分类、标签和翻译。',
|
||||
newTitle: '新增 Ancient Artifact',
|
||||
editTitle: '编辑 {name}',
|
||||
fallbackName: 'Ancient Artifact',
|
||||
loadingList: '正在加载 Ancient Artifact 列表',
|
||||
loadingDetail: '正在加载 Ancient Artifact 详情',
|
||||
loadingEdit: '正在加载 Ancient Artifact 编辑内容',
|
||||
displayId: 'Display ID',
|
||||
description: '介绍',
|
||||
category: '分类',
|
||||
tags: '标签',
|
||||
@@ -2049,6 +2084,30 @@ export const systemWordingMessages = {
|
||||
materials: '需要材料',
|
||||
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: {
|
||||
status: '正在开发中',
|
||||
heading: '这个 Wiki 分区正在准备中。',
|
||||
@@ -2166,6 +2225,8 @@ export const systemWordingMessages = {
|
||||
sortLatest: '最新',
|
||||
sortOldest: '最早',
|
||||
sortTopRated: '评分最高',
|
||||
sortMostLiked: '点赞最多',
|
||||
sortMostReplied: '回复最多',
|
||||
categoryPlaceholder: '选择 Category',
|
||||
searchCategories: '搜索 Category',
|
||||
search: '搜索动态',
|
||||
@@ -2218,6 +2279,10 @@ export const systemWordingMessages = {
|
||||
deleteCommentConfirm: '确认删除这条评论?',
|
||||
commentDeleted: '评论已删除',
|
||||
restoreComment: '撤销',
|
||||
likeComment: '点赞评论',
|
||||
unlikeComment: '取消点赞评论',
|
||||
commentLikeCount: '{count} 个赞',
|
||||
commentLikeFailed: '点赞失败',
|
||||
commentRequired: '请输入评论内容。',
|
||||
commentFailed: '评论失败',
|
||||
replyFailed: '回复失败',
|
||||
@@ -2275,6 +2340,7 @@ export const systemWordingMessages = {
|
||||
itemList: '物品列表',
|
||||
ancientArtifactList: 'Ancient Artifact 列表',
|
||||
recipeList: '材料单列表',
|
||||
dishList: '料理列表',
|
||||
habitatList: '栖息地列表',
|
||||
dataTools: '数据工具',
|
||||
dataToolRefresh: '刷新',
|
||||
@@ -2389,7 +2455,8 @@ export const systemWordingMessages = {
|
||||
acquisitionMethods: '入手方式',
|
||||
maps: '地图',
|
||||
lifeCategories: 'Life Categories',
|
||||
gameVersions: '游戏版本'
|
||||
gameVersions: '游戏版本',
|
||||
dishFlavors: '料理味道'
|
||||
},
|
||||
appearance: {
|
||||
time: '时段',
|
||||
@@ -2468,6 +2535,15 @@ export const systemWordingMessages = {
|
||||
moderationRetryFailed: '重新审核失败',
|
||||
loading: '正在加载讨论',
|
||||
loadMore: '加载更多评论',
|
||||
sort: '排序',
|
||||
sortOldest: '最早',
|
||||
sortLatest: '最新',
|
||||
sortMostLiked: '点赞最多',
|
||||
sortMostReplied: '回复最多',
|
||||
likeComment: '点赞评论',
|
||||
unlikeComment: '取消点赞评论',
|
||||
commentLikeCount: '{count} 个赞',
|
||||
commentLikeFailed: '点赞失败',
|
||||
empty: '暂无讨论',
|
||||
emptyHint: '现在发起新的讨论。',
|
||||
loginPrompt: '使用已验证邮箱登录后即可评论。',
|
||||
@@ -2562,14 +2638,12 @@ export const systemWordingMessages = {
|
||||
heightNonNegative: '身高必须是不小于 0 的数字',
|
||||
weightNonNegative: '体重必须是不小于 0 的数字',
|
||||
environmentRequired: '请选择喜欢的环境',
|
||||
skillNoDrop: '这个特长不能设置掉落物',
|
||||
habitatNameRequired: '请输入栖息地名称',
|
||||
itemDisplayIdRequired: '请输入物品 Display ID',
|
||||
usageRequired: '请选择用途',
|
||||
itemNameRequired: '请输入物品名称',
|
||||
categoryRequired: '请选择分类',
|
||||
artifactDisplayIdRequired: '请输入 Ancient Artifact Display ID',
|
||||
artifactNameRequired: '请输入 Ancient Artifact 名称',
|
||||
skillNoDrop: '这个特长不能设置掉落物',
|
||||
habitatNameRequired: '请输入栖息地名称',
|
||||
usageRequired: '请选择用途',
|
||||
itemNameRequired: '请输入物品名称',
|
||||
categoryRequired: '请选择分类',
|
||||
artifactNameRequired: '请输入 Ancient Artifact 名称',
|
||||
recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单',
|
||||
itemRequired: '请选择物品',
|
||||
recipeFreeItem: '这个物品已标记为无材料单',
|
||||
|
||||
Reference in New Issue
Block a user