feat: add ancient artifacts and refactor item categories
Introduce Ancient Artifacts with full CRUD and image support Migrate item categories and usages to system-defined lists Add display_id to items and artifacts for custom sorting
This commit is contained in:
109
DESIGN.md
109
DESIGN.md
@@ -5,7 +5,7 @@
|
|||||||
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
|
- Pokopia Wiki 是一个面向 Pokopia 游戏资料的社区 Wiki。
|
||||||
- 所有人都可以浏览 Wiki 内容。
|
- 所有人都可以浏览 Wiki 内容。
|
||||||
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
|
- 已注册并完成邮箱验证且拥有对应权限的用户可以创建、编辑、删除 Wiki 内容。
|
||||||
- 前台以 Home 首页、Pokemon、Event Pokemon、栖息地、Event Habitats、物品、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
- 前台以 Home 首页、Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日 CheckList、Life、Automation、Dish、Events、Actions、Dream Island、Clothes 为主要浏览入口。
|
||||||
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
- Home 首页路径为 `/`,用于聚合公开 Wiki 入口;Logo 导航回到 Home,用户可从 Home 进入核心资料、每日 CheckList、Life 和正在准备中的分区。
|
||||||
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||||
|
|
||||||
@@ -53,10 +53,9 @@
|
|||||||
- Pokemon Types
|
- Pokemon Types
|
||||||
- 喜欢的环境
|
- 喜欢的环境
|
||||||
- 喜欢的东西 / 标签
|
- 喜欢的东西 / 标签
|
||||||
- 物品分类
|
|
||||||
- 物品用途
|
|
||||||
- 入手方式
|
- 入手方式
|
||||||
- 物品
|
- 物品
|
||||||
|
- Ancient Artifacts
|
||||||
- 地图
|
- 地图
|
||||||
- 栖息地
|
- 栖息地
|
||||||
- 每日 CheckList Task
|
- 每日 CheckList Task
|
||||||
@@ -65,7 +64,7 @@
|
|||||||
- 支持翻译的字段:
|
- 支持翻译的字段:
|
||||||
- `name`
|
- `name`
|
||||||
- `title`
|
- `title`
|
||||||
- `details`:仅 Pokemon 介绍使用
|
- `details`:Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
|
||||||
- `genus`:仅 Pokemon Genus 使用
|
- `genus`:仅 Pokemon Genus 使用
|
||||||
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
||||||
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
||||||
@@ -196,6 +195,7 @@
|
|||||||
- Pokemon
|
- Pokemon
|
||||||
- Habitats
|
- Habitats
|
||||||
- Items
|
- Items
|
||||||
|
- Ancient Artifacts
|
||||||
- Recipes
|
- Recipes
|
||||||
- Daily CheckList
|
- Daily CheckList
|
||||||
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes,前端确认内容也必须显示 Recipes。
|
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes,前端确认内容也必须显示 Recipes。
|
||||||
@@ -204,6 +204,7 @@
|
|||||||
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
||||||
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
||||||
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
|
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
|
||||||
|
- Wipe Ancient Artifacts 会删除 Ancient Artifacts、标签关联、实体翻译、编辑历史和实体讨论评论。
|
||||||
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
||||||
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
||||||
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
||||||
@@ -378,7 +379,7 @@
|
|||||||
|
|
||||||
## 全局配置数据
|
## 全局配置数据
|
||||||
|
|
||||||
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。
|
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。物品分类、物品用途和 Ancient Artifacts 分类是代码维护的系统固定列表,不属于可配置数据。
|
||||||
|
|
||||||
### 特长
|
### 特长
|
||||||
|
|
||||||
@@ -404,16 +405,6 @@
|
|||||||
- Pokemon 喜欢的东西
|
- Pokemon 喜欢的东西
|
||||||
- 物品标签
|
- 物品标签
|
||||||
|
|
||||||
### 物品分类
|
|
||||||
|
|
||||||
- 名称
|
|
||||||
- 用于物品和材料单按结果物品分类展示。
|
|
||||||
|
|
||||||
### 物品用途
|
|
||||||
|
|
||||||
- 名称
|
|
||||||
- 物品用途可为空。
|
|
||||||
|
|
||||||
### 入手方式
|
### 入手方式
|
||||||
|
|
||||||
- 名称
|
- 名称
|
||||||
@@ -552,10 +543,28 @@ Pokemon 详情页展示:
|
|||||||
|
|
||||||
物品可配置:
|
物品可配置:
|
||||||
|
|
||||||
|
- Display ID:用于物品和 Event Items 各自列表内展示与排序;`display_id` 与 `is_event_item` 组合唯一
|
||||||
- 名称
|
- 名称
|
||||||
- 是否为 Event Habitat:`is_event_item`
|
- 介绍
|
||||||
- 分类:必填
|
- 是否为 Event Item:`is_event_item`
|
||||||
- 用途:可为空
|
- 分类:必填,使用系统固定列表,不在管理端配置:
|
||||||
|
- Furniture
|
||||||
|
- Misc
|
||||||
|
- Outdoor
|
||||||
|
- Utilities
|
||||||
|
- Buildings
|
||||||
|
- Blocks
|
||||||
|
- Kits
|
||||||
|
- Nature
|
||||||
|
- Food
|
||||||
|
- Materials
|
||||||
|
- Key Items
|
||||||
|
- Other
|
||||||
|
- 用途:可为空,使用系统固定列表,不在管理端配置:
|
||||||
|
- Decoration
|
||||||
|
- Relaxation
|
||||||
|
- Toy
|
||||||
|
- Road
|
||||||
- 入手方式:可多选
|
- 入手方式:可多选
|
||||||
- 客制化:
|
- 客制化:
|
||||||
- 可染色
|
- 可染色
|
||||||
@@ -567,14 +576,20 @@ Pokemon 详情页展示:
|
|||||||
- 翻译
|
- 翻译
|
||||||
- 排序
|
- 排序
|
||||||
|
|
||||||
|
Items 与 Event Items 使用相同数据模型:
|
||||||
|
|
||||||
|
- Items 列表只展示 `is_event_item = false` 的物品。
|
||||||
|
- Event Items 列表只展示 `is_event_item = true` 的物品。
|
||||||
|
- Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
|
||||||
|
|
||||||
物品列表功能:
|
物品列表功能:
|
||||||
|
|
||||||
- 搜索
|
- 搜索
|
||||||
- 按分类展示为标签页
|
- 按分类展示为标签页
|
||||||
- 按用途筛选
|
- 按用途筛选
|
||||||
- 按标签筛选
|
- 按标签筛选
|
||||||
- 按自定义排序展示
|
- 按 Display ID 和自定义排序展示
|
||||||
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、名称和分类;不展示标签、入手方式或编辑元信息。
|
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、`#Display ID 名称` 和分类;不展示标签、入手方式或编辑元信息。
|
||||||
- 有用途的物品在卡片左上角以斜 Ribbon 展示用途名称。
|
- 有用途的物品在卡片左上角以斜 Ribbon 展示用途名称。
|
||||||
- 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。
|
- 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。
|
||||||
|
|
||||||
@@ -583,6 +598,8 @@ Pokemon 详情页展示:
|
|||||||
- 基本信息
|
- 基本信息
|
||||||
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
||||||
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
||||||
|
- Display ID
|
||||||
|
- 介绍
|
||||||
- 分类
|
- 分类
|
||||||
- 用途
|
- 用途
|
||||||
- 入手方式
|
- 入手方式
|
||||||
@@ -596,6 +613,42 @@ Pokemon 详情页展示:
|
|||||||
- 讨论
|
- 讨论
|
||||||
- 编辑历史
|
- 编辑历史
|
||||||
|
|
||||||
|
## Ancient Artifacts
|
||||||
|
|
||||||
|
Ancient Artifacts 是独立 Wiki 内容类型,可配置:
|
||||||
|
|
||||||
|
- Display ID:用于展示与排序
|
||||||
|
- 名称
|
||||||
|
- 介绍
|
||||||
|
- 图片:使用 Ancient Artifacts 上传目录,支持图片历史
|
||||||
|
- 分类:必填,使用系统固定列表,不在管理端配置:
|
||||||
|
- Lost Relics (L)
|
||||||
|
- Lost Relics (S)
|
||||||
|
- Fossils
|
||||||
|
- 标签:复用全局“喜欢的东西 / 标签”配置,可多选
|
||||||
|
- 翻译
|
||||||
|
- 排序
|
||||||
|
|
||||||
|
Ancient Artifacts 列表功能:
|
||||||
|
|
||||||
|
- 搜索
|
||||||
|
- 按分类展示为标签页
|
||||||
|
- 按标签筛选
|
||||||
|
- 按 Display ID 和自定义排序展示
|
||||||
|
- 列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,展示图片 / 默认 Ancient Artifact 标记、`#Display ID 名称` 和分类;不展示编辑元信息。
|
||||||
|
|
||||||
|
Ancient Artifacts 详情页展示:
|
||||||
|
|
||||||
|
- Display ID
|
||||||
|
- 名称
|
||||||
|
- 图片;未配置图片时展示默认 Ancient Artifact 标记
|
||||||
|
- 介绍
|
||||||
|
- 分类
|
||||||
|
- 标签
|
||||||
|
- 最后编辑信息
|
||||||
|
- 讨论
|
||||||
|
- 编辑历史
|
||||||
|
|
||||||
## 材料单
|
## 材料单
|
||||||
|
|
||||||
材料单与物品是一对一关系:
|
材料单与物品是一对一关系:
|
||||||
@@ -616,8 +669,8 @@ Pokemon 详情页展示:
|
|||||||
|
|
||||||
- 独立于物品列表展示
|
- 独立于物品列表展示
|
||||||
- 按结果物品分类展示
|
- 按结果物品分类展示
|
||||||
- 按自定义排序展示
|
- 按结果物品 Display ID 和自定义排序展示
|
||||||
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、名称和分类;不展示编辑元信息。
|
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、`#Display ID 名称` 和分类;不展示编辑元信息。
|
||||||
- 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。
|
- 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。
|
||||||
- Create Recipe 按钮展示在结果物品名称下方;已有材料单的卡片保留同等按钮空间但不显示按钮;标记为无材料单的物品展示禁用按钮;可创建材料单的物品展示可点击按钮并进入创建流程。
|
- Create Recipe 按钮展示在结果物品名称下方;已有材料单的卡片保留同等按钮空间但不显示按钮;标记为无材料单的物品展示禁用按钮;可创建材料单的物品展示可点击按钮并进入创建流程。
|
||||||
|
|
||||||
@@ -820,6 +873,7 @@ API 暴露边界:
|
|||||||
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
||||||
- 配置:System config。
|
- 配置:System config。
|
||||||
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。
|
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。
|
||||||
|
- 内容管理包含 Items、Event Items 与 Ancient Artifacts;Items / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
|
||||||
- 本地化:Languages、System wordings。
|
- 本地化:Languages、System wordings。
|
||||||
- 访问权限:Users、Roles、Permissions、Rate limits。
|
- 访问权限:Users、Roles、Permissions、Rate limits。
|
||||||
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||||
@@ -837,7 +891,10 @@ API 暴露边界:
|
|||||||
- `/event-habitats/new`
|
- `/event-habitats/new`
|
||||||
- `/habitats/:id/edit`
|
- `/habitats/:id/edit`
|
||||||
- `/items/new`
|
- `/items/new`
|
||||||
|
- `/event-items/new`
|
||||||
- `/items/:id/edit`
|
- `/items/:id/edit`
|
||||||
|
- `/ancient-artifacts/new`
|
||||||
|
- `/ancient-artifacts/:id/edit`
|
||||||
- `/recipes/new`
|
- `/recipes/new`
|
||||||
- `/recipes/:id/edit`
|
- `/recipes/:id/edit`
|
||||||
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
|
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
|
||||||
@@ -859,6 +916,8 @@ API 暴露边界:
|
|||||||
- `/habitats`
|
- `/habitats`
|
||||||
- `/event-habitats`
|
- `/event-habitats`
|
||||||
- `/items`
|
- `/items`
|
||||||
|
- `/event-items`
|
||||||
|
- `/ancient-artifacts`
|
||||||
- `/recipes`
|
- `/recipes`
|
||||||
- `/checklist`
|
- `/checklist`
|
||||||
- `/life`
|
- `/life`
|
||||||
@@ -883,8 +942,10 @@ API 暴露边界:
|
|||||||
- `GET /api/pokemon/:id`
|
- `GET /api/pokemon/:id`
|
||||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
||||||
- `GET /api/habitats/:id`
|
- `GET /api/habitats/:id`
|
||||||
- `GET /api/items`
|
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;未传时返回全部 Items 以兼容管理端和实体选择器
|
||||||
- `GET /api/items/:id`
|
- `GET /api/items/:id`
|
||||||
|
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选
|
||||||
|
- `GET /api/ancient-artifacts/:id`
|
||||||
- `GET /api/recipes`
|
- `GET /api/recipes`
|
||||||
- `GET /api/recipes/:id`
|
- `GET /api/recipes/:id`
|
||||||
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
- `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。
|
||||||
@@ -893,7 +954,7 @@ API 暴露边界:
|
|||||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||||
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
- `GET /api/users/:id/comments`:分页读取该用户未删除的 Life 评论和实体讨论评论。
|
||||||
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`。
|
- `GET /api/discussions/:entityType/:entityId/comments`:支持 `cursor` / `limit` 分页读取实体讨论;支持 `language` 按审核语言区筛选;`entityType` 支持 `pokemon`、`items`、`recipes`、`habitats`、`ancient-artifacts`。
|
||||||
|
|
||||||
认证 API:
|
认证 API:
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
'item-usages',
|
'item-usages',
|
||||||
'acquisition-methods',
|
'acquisition-methods',
|
||||||
'items',
|
'items',
|
||||||
|
'ancient-artifacts',
|
||||||
'maps',
|
'maps',
|
||||||
'habitats',
|
'habitats',
|
||||||
'daily-checklist-items',
|
'daily-checklist-items',
|
||||||
'life-tags'
|
'life-tags',
|
||||||
|
'game-versions'
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
entity_id integer NOT NULL,
|
entity_id integer NOT NULL,
|
||||||
@@ -46,6 +48,30 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
|||||||
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
|
||||||
ON entity_translations (entity_type, entity_id, field_name, locale);
|
ON entity_translations (entity_type, entity_id, field_name, locale);
|
||||||
|
|
||||||
|
ALTER TABLE entity_translations
|
||||||
|
DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE entity_translations
|
||||||
|
ADD CONSTRAINT entity_translations_entity_type_check CHECK (
|
||||||
|
entity_type IN (
|
||||||
|
'pokemon',
|
||||||
|
'pokemon-types',
|
||||||
|
'skills',
|
||||||
|
'environments',
|
||||||
|
'favorite-things',
|
||||||
|
'item-categories',
|
||||||
|
'item-usages',
|
||||||
|
'acquisition-methods',
|
||||||
|
'items',
|
||||||
|
'ancient-artifacts',
|
||||||
|
'maps',
|
||||||
|
'habitats',
|
||||||
|
'daily-checklist-items',
|
||||||
|
'life-tags',
|
||||||
|
'game-versions'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
email text NOT NULL UNIQUE,
|
email text NOT NULL UNIQUE,
|
||||||
@@ -241,6 +267,11 @@ VALUES
|
|||||||
('items.delete', 'Delete items', 'Delete item records.', 'Items', true),
|
('items.delete', 'Delete items', 'Delete item records.', 'Items', true),
|
||||||
('items.order', 'Order items', 'Reorder item records.', 'Items', true),
|
('items.order', 'Order items', 'Reorder item records.', 'Items', true),
|
||||||
('items.upload', 'Upload item images', 'Upload item images.', 'Items', true),
|
('items.upload', 'Upload item images', 'Upload item images.', 'Items', true),
|
||||||
|
('ancient-artifacts.create', 'Create Ancient Artifacts', 'Create Ancient Artifact records.', 'Ancient Artifacts', true),
|
||||||
|
('ancient-artifacts.update', 'Update Ancient Artifacts', 'Edit Ancient Artifact records.', 'Ancient Artifacts', true),
|
||||||
|
('ancient-artifacts.delete', 'Delete Ancient Artifacts', 'Delete Ancient Artifact records.', 'Ancient Artifacts', true),
|
||||||
|
('ancient-artifacts.order', 'Order Ancient Artifacts', 'Reorder Ancient Artifact records.', 'Ancient Artifacts', true),
|
||||||
|
('ancient-artifacts.upload', 'Upload Ancient Artifact images', 'Upload Ancient Artifact images.', 'Ancient Artifacts', true),
|
||||||
('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true),
|
('recipes.create', 'Create recipes', 'Create recipe records.', 'Recipes', true),
|
||||||
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
|
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
|
||||||
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
|
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
|
||||||
@@ -327,6 +358,11 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'items.delete',
|
'items.delete',
|
||||||
'items.order',
|
'items.order',
|
||||||
'items.upload',
|
'items.upload',
|
||||||
|
'ancient-artifacts.create',
|
||||||
|
'ancient-artifacts.update',
|
||||||
|
'ancient-artifacts.delete',
|
||||||
|
'ancient-artifacts.order',
|
||||||
|
'ancient-artifacts.upload',
|
||||||
'recipes.create',
|
'recipes.create',
|
||||||
'recipes.update',
|
'recipes.update',
|
||||||
'recipes.delete',
|
'recipes.delete',
|
||||||
@@ -395,6 +431,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
|||||||
'items.update',
|
'items.update',
|
||||||
'items.order',
|
'items.order',
|
||||||
'items.upload',
|
'items.upload',
|
||||||
|
'ancient-artifacts.create',
|
||||||
|
'ancient-artifacts.update',
|
||||||
|
'ancient-artifacts.order',
|
||||||
|
'ancient-artifacts.upload',
|
||||||
'recipes.create',
|
'recipes.create',
|
||||||
'recipes.update',
|
'recipes.update',
|
||||||
'recipes.order',
|
'recipes.order',
|
||||||
@@ -416,6 +456,31 @@ WHERE r.key = 'editor'
|
|||||||
)
|
)
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.key = ANY (ARRAY[
|
||||||
|
'ancient-artifacts.create',
|
||||||
|
'ancient-artifacts.update',
|
||||||
|
'ancient-artifacts.delete',
|
||||||
|
'ancient-artifacts.order',
|
||||||
|
'ancient-artifacts.upload'
|
||||||
|
])
|
||||||
|
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[
|
||||||
|
'ancient-artifacts.create',
|
||||||
|
'ancient-artifacts.update',
|
||||||
|
'ancient-artifacts.order',
|
||||||
|
'ancient-artifacts.upload'
|
||||||
|
])
|
||||||
|
WHERE r.key = 'editor'
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO role_permissions (role_id, permission_id)
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
SELECT r.id, p.id
|
SELECT r.id, p.id
|
||||||
FROM roles r
|
FROM roles r
|
||||||
@@ -798,8 +863,12 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS items (
|
CREATE TABLE IF NOT EXISTS items (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
display_id integer NOT NULL CHECK (display_id > 0),
|
||||||
name text NOT NULL UNIQUE,
|
name text NOT NULL UNIQUE,
|
||||||
category_id integer NOT NULL REFERENCES item_categories(id),
|
details text NOT NULL DEFAULT '',
|
||||||
|
category_key text NOT NULL DEFAULT 'other',
|
||||||
|
usage_key text,
|
||||||
|
category_id integer REFERENCES item_categories(id),
|
||||||
usage_id integer REFERENCES item_usages(id),
|
usage_id integer REFERENCES item_usages(id),
|
||||||
dyeable boolean NOT NULL DEFAULT false,
|
dyeable boolean NOT NULL DEFAULT false,
|
||||||
dual_dyeable boolean NOT NULL DEFAULT false,
|
dual_dyeable boolean NOT NULL DEFAULT false,
|
||||||
@@ -811,6 +880,35 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||||
updated_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(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (category_key IN (
|
||||||
|
'furniture',
|
||||||
|
'misc',
|
||||||
|
'outdoor',
|
||||||
|
'utilities',
|
||||||
|
'buildings',
|
||||||
|
'blocks',
|
||||||
|
'kits',
|
||||||
|
'nature',
|
||||||
|
'food',
|
||||||
|
'materials',
|
||||||
|
'key-items',
|
||||||
|
'other'
|
||||||
|
)),
|
||||||
|
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'))
|
||||||
|
);
|
||||||
|
|
||||||
|
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')),
|
||||||
|
image_path 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()
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -842,6 +940,12 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
|
|||||||
PRIMARY KEY (item_id, favorite_thing_id)
|
PRIMARY KEY (item_id, favorite_thing_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ancient_artifact_favorite_things (
|
||||||
|
ancient_artifact_id integer NOT NULL REFERENCES ancient_artifacts(id) ON DELETE CASCADE,
|
||||||
|
favorite_thing_id integer NOT NULL REFERENCES favorite_things(id),
|
||||||
|
PRIMARY KEY (ancient_artifact_id, favorite_thing_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
|
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
|
||||||
pokemon_id integer NOT NULL,
|
pokemon_id integer NOT NULL,
|
||||||
skill_id integer NOT NULL,
|
skill_id integer NOT NULL,
|
||||||
@@ -899,6 +1003,116 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
|||||||
ALTER TABLE life_tags
|
ALTER TABLE life_tags
|
||||||
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE items
|
||||||
|
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;
|
||||||
|
|
||||||
|
ALTER TABLE ancient_artifacts
|
||||||
|
ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'items'
|
||||||
|
AND column_name = 'category_id'
|
||||||
|
AND table_schema = current_schema()
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE items ALTER COLUMN category_id DROP NOT NULL;
|
||||||
|
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'
|
||||||
|
WHEN 'misc' THEN 'misc'
|
||||||
|
WHEN 'outdoor' THEN 'outdoor'
|
||||||
|
WHEN 'utilities' THEN 'utilities'
|
||||||
|
WHEN 'buildings' THEN 'buildings'
|
||||||
|
WHEN 'blocks' THEN 'blocks'
|
||||||
|
WHEN 'kits' THEN 'kits'
|
||||||
|
WHEN 'nature' THEN 'nature'
|
||||||
|
WHEN 'food' THEN 'food'
|
||||||
|
WHEN 'materials' THEN 'materials'
|
||||||
|
WHEN 'key items' THEN 'key-items'
|
||||||
|
WHEN 'key-items' THEN 'key-items'
|
||||||
|
WHEN 'other' THEN 'other'
|
||||||
|
ELSE 'other'
|
||||||
|
END
|
||||||
|
FROM item_categories c
|
||||||
|
WHERE i.category_id = c.id
|
||||||
|
AND (i.category_key IS NULL OR i.category_key = '');
|
||||||
|
|
||||||
|
UPDATE items i
|
||||||
|
SET usage_key = CASE lower(trim(u.name))
|
||||||
|
WHEN 'decoration' THEN 'decoration'
|
||||||
|
WHEN 'relaxation' THEN 'relaxation'
|
||||||
|
WHEN 'toy' THEN 'toy'
|
||||||
|
WHEN 'road' THEN 'road'
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
FROM item_usages u
|
||||||
|
WHERE i.usage_id = u.id
|
||||||
|
AND i.usage_key IS NULL;
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET category_key = 'other'
|
||||||
|
WHERE category_key IS NULL
|
||||||
|
OR category_key NOT IN (
|
||||||
|
'furniture',
|
||||||
|
'misc',
|
||||||
|
'outdoor',
|
||||||
|
'utilities',
|
||||||
|
'buildings',
|
||||||
|
'blocks',
|
||||||
|
'kits',
|
||||||
|
'nature',
|
||||||
|
'food',
|
||||||
|
'materials',
|
||||||
|
'key-items',
|
||||||
|
'other'
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE items
|
||||||
|
SET usage_key = NULL
|
||||||
|
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';
|
||||||
|
|
||||||
|
ALTER TABLE items
|
||||||
|
DROP CONSTRAINT IF EXISTS items_display_id_positive,
|
||||||
|
DROP CONSTRAINT IF EXISTS items_category_key_check,
|
||||||
|
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',
|
||||||
|
'outdoor',
|
||||||
|
'utilities',
|
||||||
|
'buildings',
|
||||||
|
'blocks',
|
||||||
|
'kits',
|
||||||
|
'nature',
|
||||||
|
'food',
|
||||||
|
'materials',
|
||||||
|
'key-items',
|
||||||
|
'other'
|
||||||
|
)),
|
||||||
|
ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road'));
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
|
||||||
@@ -911,6 +1125,10 @@ CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sor
|
|||||||
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
CREATE INDEX IF NOT EXISTS acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
|
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS items_display_event_item_key ON items(display_id, is_event_item);
|
||||||
|
CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, display_id, sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
|
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id);
|
||||||
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
|
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);
|
||||||
@@ -933,7 +1151,7 @@ CREATE INDEX IF NOT EXISTS wiki_edit_logs_user_id_idx
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entity_image_uploads (
|
CREATE TABLE IF NOT EXISTS entity_image_uploads (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats')),
|
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')),
|
||||||
entity_id integer,
|
entity_id integer,
|
||||||
entity_name text NOT NULL,
|
entity_name text NOT NULL,
|
||||||
path text NOT NULL UNIQUE,
|
path text NOT NULL UNIQUE,
|
||||||
@@ -946,6 +1164,14 @@ CREATE TABLE IF NOT EXISTS entity_image_uploads (
|
|||||||
CHECK (path !~ '(^/|\\.\\.)')
|
CHECK (path !~ '(^/|\\.\\.)')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE entity_image_uploads
|
||||||
|
DROP CONSTRAINT IF EXISTS entity_image_uploads_entity_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE entity_image_uploads
|
||||||
|
ADD CONSTRAINT entity_image_uploads_entity_type_check CHECK (
|
||||||
|
entity_type IN ('pokemon', 'items', 'habitats', 'ancient-artifacts')
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
|
CREATE INDEX IF NOT EXISTS entity_image_uploads_entity_idx
|
||||||
ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);
|
ON entity_image_uploads(entity_type, entity_id, created_at DESC, id DESC);
|
||||||
|
|
||||||
@@ -954,7 +1180,7 @@ CREATE INDEX IF NOT EXISTS entity_image_uploads_user_idx
|
|||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
|
CREATE TABLE IF NOT EXISTS entity_discussion_comments (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats')),
|
entity_type text NOT NULL CHECK (entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')),
|
||||||
entity_id integer NOT NULL,
|
entity_id integer NOT NULL,
|
||||||
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
||||||
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
|
body text NOT NULL CHECK (length(body) BETWEEN 1 AND 1000),
|
||||||
@@ -980,6 +1206,14 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_parent_idx
|
|||||||
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
|
CREATE INDEX IF NOT EXISTS entity_discussion_comments_user_idx
|
||||||
ON entity_discussion_comments(created_by_user_id);
|
ON entity_discussion_comments(created_by_user_id);
|
||||||
|
|
||||||
|
ALTER TABLE entity_discussion_comments
|
||||||
|
DROP CONSTRAINT IF EXISTS entity_discussion_comments_entity_type_check;
|
||||||
|
|
||||||
|
ALTER TABLE entity_discussion_comments
|
||||||
|
ADD CONSTRAINT entity_discussion_comments_entity_type_check CHECK (
|
||||||
|
entity_type IN ('pokemon', 'items', 'recipes', 'habitats', 'ancient-artifacts')
|
||||||
|
);
|
||||||
|
|
||||||
ALTER TABLE life_tags
|
ALTER TABLE life_tags
|
||||||
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS is_rateable boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type QueryValue = string | string[] | undefined;
|
|||||||
type QueryParams = Record<string, QueryValue>;
|
type QueryParams = Record<string, QueryValue>;
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
|
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||||
type DataToolScopeSummary = {
|
type DataToolScopeSummary = {
|
||||||
scope: DataToolScope;
|
scope: DataToolScope;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -48,6 +48,7 @@ type EntityType =
|
|||||||
| 'item-usages'
|
| 'item-usages'
|
||||||
| 'acquisition-methods'
|
| 'acquisition-methods'
|
||||||
| 'items'
|
| 'items'
|
||||||
|
| 'ancient-artifacts'
|
||||||
| 'maps'
|
| 'maps'
|
||||||
| 'habitats'
|
| 'habitats'
|
||||||
| 'daily-checklist-items'
|
| 'daily-checklist-items'
|
||||||
@@ -59,8 +60,6 @@ type ConfigType =
|
|||||||
| 'skills'
|
| 'skills'
|
||||||
| 'environments'
|
| 'environments'
|
||||||
| 'favorite-things'
|
| 'favorite-things'
|
||||||
| 'item-categories'
|
|
||||||
| 'item-usages'
|
|
||||||
| 'acquisition-methods'
|
| 'acquisition-methods'
|
||||||
| 'maps'
|
| 'maps'
|
||||||
| 'life-tags'
|
| 'life-tags'
|
||||||
@@ -74,7 +73,7 @@ type ConfigDefinition = {
|
|||||||
hasRateable?: boolean;
|
hasRateable?: boolean;
|
||||||
hasChangeLog?: boolean;
|
hasChangeLog?: boolean;
|
||||||
};
|
};
|
||||||
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
|
||||||
type SortableContentDefinition = {
|
type SortableContentDefinition = {
|
||||||
table: string;
|
table: string;
|
||||||
entityType: SortableContentType;
|
entityType: SortableContentType;
|
||||||
@@ -171,10 +170,14 @@ type PokemonCsvData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ItemPayload = {
|
type ItemPayload = {
|
||||||
|
displayId: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
details: string;
|
||||||
translations: TranslationInput;
|
translations: TranslationInput;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
|
categoryKey: string;
|
||||||
usageId: number | null;
|
usageId: number | null;
|
||||||
|
usageKey: string | null;
|
||||||
dyeable: boolean;
|
dyeable: boolean;
|
||||||
dualDyeable: boolean;
|
dualDyeable: boolean;
|
||||||
patternEditable: boolean;
|
patternEditable: boolean;
|
||||||
@@ -185,6 +188,17 @@ type ItemPayload = {
|
|||||||
imagePath: string;
|
imagePath: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AncientArtifactPayload = {
|
||||||
|
displayId: number;
|
||||||
|
name: string;
|
||||||
|
details: string;
|
||||||
|
translations: TranslationInput;
|
||||||
|
categoryId: number;
|
||||||
|
categoryKey: string;
|
||||||
|
tagIds: number[];
|
||||||
|
imagePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
type RecipePayload = {
|
type RecipePayload = {
|
||||||
itemId: number;
|
itemId: number;
|
||||||
acquisitionMethodIds: number[];
|
acquisitionMethodIds: number[];
|
||||||
@@ -208,7 +222,7 @@ type LifeCommentPayload = {
|
|||||||
languageCode: string | null;
|
languageCode: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
||||||
type DiscussionEntityDefinition = {
|
type DiscussionEntityDefinition = {
|
||||||
table: string;
|
table: string;
|
||||||
};
|
};
|
||||||
@@ -436,7 +450,9 @@ type PokemonChangeSource = {
|
|||||||
favorite_things: Array<{ name: string }>;
|
favorite_things: Array<{ name: string }>;
|
||||||
} & TranslationChangeSource;
|
} & TranslationChangeSource;
|
||||||
type ItemChangeSource = {
|
type ItemChangeSource = {
|
||||||
|
displayId: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
details: string;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
image: EntityImageValue | null;
|
image: EntityImageValue | null;
|
||||||
category: { name: string };
|
category: { name: string };
|
||||||
@@ -446,6 +462,14 @@ type ItemChangeSource = {
|
|||||||
acquisitionMethods: Array<{ name: string }>;
|
acquisitionMethods: Array<{ name: string }>;
|
||||||
tags: Array<{ name: string }>;
|
tags: Array<{ name: string }>;
|
||||||
} & TranslationChangeSource;
|
} & TranslationChangeSource;
|
||||||
|
type AncientArtifactChangeSource = {
|
||||||
|
displayId: number;
|
||||||
|
name: string;
|
||||||
|
details: string;
|
||||||
|
image: EntityImageValue | null;
|
||||||
|
category: { name: string };
|
||||||
|
tags: Array<{ name: string }>;
|
||||||
|
} & TranslationChangeSource;
|
||||||
type HabitatChangeSource = {
|
type HabitatChangeSource = {
|
||||||
name: string;
|
name: string;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
@@ -491,13 +515,45 @@ const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
|||||||
{ key: 'speed', label: 'Speed' }
|
{ key: 'speed', label: 'Speed' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type SystemListOption = {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
labels: Record<typeof defaultLocale | 'zh-CN', string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemCategoryOptions = [
|
||||||
|
{ id: 1, key: 'furniture', labels: { en: 'Furniture', 'zh-CN': '家具' } },
|
||||||
|
{ id: 2, key: 'misc', labels: { en: 'Misc', 'zh-CN': '杂项' } },
|
||||||
|
{ id: 3, key: 'outdoor', labels: { en: 'Outdoor', 'zh-CN': '户外' } },
|
||||||
|
{ id: 4, key: 'utilities', labels: { en: 'Utilities', 'zh-CN': '实用工具' } },
|
||||||
|
{ id: 5, key: 'buildings', labels: { en: 'Buildings', 'zh-CN': '建筑' } },
|
||||||
|
{ id: 6, key: 'blocks', labels: { en: 'Blocks', 'zh-CN': '方块' } },
|
||||||
|
{ id: 7, key: 'kits', labels: { en: 'Kits', 'zh-CN': '套件' } },
|
||||||
|
{ id: 8, key: 'nature', labels: { en: 'Nature', 'zh-CN': '自然' } },
|
||||||
|
{ id: 9, key: 'food', labels: { en: 'Food', 'zh-CN': '食物' } },
|
||||||
|
{ id: 10, key: 'materials', labels: { en: 'Materials', 'zh-CN': '材料' } },
|
||||||
|
{ id: 11, key: 'key-items', labels: { en: 'Key Items', 'zh-CN': '关键物品' } },
|
||||||
|
{ id: 12, key: 'other', labels: { en: 'Other', 'zh-CN': '其他' } }
|
||||||
|
] as const satisfies readonly SystemListOption[];
|
||||||
|
|
||||||
|
const itemUsageOptions = [
|
||||||
|
{ id: 1, key: 'decoration', labels: { en: 'Decoration', 'zh-CN': '装饰' } },
|
||||||
|
{ id: 2, key: 'relaxation', labels: { en: 'Relaxation', 'zh-CN': '休闲' } },
|
||||||
|
{ id: 3, key: 'toy', labels: { en: 'Toy', 'zh-CN': '玩具' } },
|
||||||
|
{ id: 4, key: 'road', labels: { en: 'Road', 'zh-CN': '道路' } }
|
||||||
|
] as const satisfies readonly SystemListOption[];
|
||||||
|
|
||||||
|
const ancientArtifactCategoryOptions = [
|
||||||
|
{ id: 1, key: 'lost-relics-l', labels: { en: 'Lost Relics (L)', 'zh-CN': 'Lost Relics (L)' } },
|
||||||
|
{ id: 2, key: 'lost-relics-s', labels: { en: 'Lost Relics (S)', 'zh-CN': 'Lost Relics (S)' } },
|
||||||
|
{ id: 3, key: 'fossils', labels: { en: 'Fossils', 'zh-CN': '化石' } }
|
||||||
|
] as const satisfies readonly SystemListOption[];
|
||||||
|
|
||||||
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
||||||
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
|
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
|
||||||
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
|
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
|
||||||
environments: { table: 'environments', entityType: 'environments' },
|
environments: { table: 'environments', entityType: 'environments' },
|
||||||
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
|
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
|
||||||
'item-categories': { table: 'item_categories', entityType: 'item-categories' },
|
|
||||||
'item-usages': { table: 'item_usages', entityType: 'item-usages' },
|
|
||||||
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
|
||||||
maps: { table: 'maps', entityType: 'maps' },
|
maps: { table: 'maps', entityType: 'maps' },
|
||||||
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
|
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
|
||||||
@@ -507,6 +563,7 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
|
|||||||
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
|
||||||
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
||||||
items: { table: 'items', entityType: 'items' },
|
items: { table: 'items', entityType: 'items' },
|
||||||
|
'ancient-artifacts': { table: 'ancient_artifacts', entityType: 'ancient-artifacts' },
|
||||||
recipes: { table: 'recipes', entityType: 'recipes' },
|
recipes: { table: 'recipes', entityType: 'recipes' },
|
||||||
habitats: { table: 'habitats', entityType: 'habitats' }
|
habitats: { table: 'habitats', entityType: 'habitats' }
|
||||||
};
|
};
|
||||||
@@ -515,7 +572,8 @@ const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntity
|
|||||||
pokemon: { table: 'pokemon' },
|
pokemon: { table: 'pokemon' },
|
||||||
items: { table: 'items' },
|
items: { table: 'items' },
|
||||||
recipes: { table: 'recipes' },
|
recipes: { table: 'recipes' },
|
||||||
habitats: { table: 'habitats' }
|
habitats: { table: 'habitats' },
|
||||||
|
'ancient-artifacts': { table: 'ancient_artifacts' }
|
||||||
};
|
};
|
||||||
|
|
||||||
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
|
let pokemonCsvDataCache: Promise<PokemonCsvData> | null = null;
|
||||||
@@ -726,6 +784,52 @@ function optionSelect(
|
|||||||
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${orderByEntity('o')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function systemListLabel(option: SystemListOption, locale: string): string {
|
||||||
|
const clean = cleanLocale(locale) as keyof SystemListOption['labels'];
|
||||||
|
return option.labels[clean] ?? option.labels[defaultLocale];
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemListOptions(options: readonly SystemListOption[], locale: string): Array<{ id: number; key: string; name: string }> {
|
||||||
|
return options.map((option) => ({ id: option.id, key: option.key, name: systemListLabel(option, locale) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemListOptionById(
|
||||||
|
options: readonly SystemListOption[],
|
||||||
|
id: number,
|
||||||
|
message: string
|
||||||
|
): SystemListOption {
|
||||||
|
const option = options.find((item) => item.id === id);
|
||||||
|
if (!option) {
|
||||||
|
throw validationError(message);
|
||||||
|
}
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemListOptionByKey(options: readonly SystemListOption[], key: string | null | undefined): SystemListOption | null {
|
||||||
|
return options.find((item) => item.key === key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemListNameByKey(options: readonly SystemListOption[], key: string | null | undefined, locale = defaultLocale): string | null {
|
||||||
|
const option = systemListOptionByKey(options, key);
|
||||||
|
return option ? systemListLabel(option, locale) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemListIdSql(expression: string, options: readonly SystemListOption[]): string {
|
||||||
|
const cases = options.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${option.id}`).join(' ');
|
||||||
|
return `CASE ${expression} ${cases} ELSE NULL END`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemListNameSql(expression: string, options: readonly SystemListOption[], locale: string): string {
|
||||||
|
const cases = options
|
||||||
|
.map((option) => `WHEN ${sqlLiteral(option.key)} THEN ${sqlLiteral(systemListLabel(option, locale))}`)
|
||||||
|
.join(' ');
|
||||||
|
return `CASE ${expression} ${cases} ELSE '' END`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemListJsonSql(expression: string, options: readonly SystemListOption[], locale: string): string {
|
||||||
|
return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`;
|
||||||
|
}
|
||||||
|
|
||||||
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean; isRateable: boolean }>> {
|
function lifeCategoryOptions(locale: string): Promise<Array<{ id: number; name: string; isDefault: boolean; isRateable: boolean }>> {
|
||||||
const name = localizedName('life-tags', 'lc', locale);
|
const name = localizedName('life-tags', 'lc', locale);
|
||||||
return query(
|
return query(
|
||||||
@@ -821,7 +925,7 @@ function cleanOptionalText(value: unknown): string {
|
|||||||
return typeof value === 'string' ? value.trim() : '';
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats'): string {
|
function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | 'ancient-artifacts'): string {
|
||||||
const imagePath = cleanOptionalText(value);
|
const imagePath = cleanOptionalText(value);
|
||||||
if (imagePath === '') {
|
if (imagePath === '') {
|
||||||
return '';
|
return '';
|
||||||
@@ -2030,17 +2134,17 @@ async function itemEditChanges(
|
|||||||
after: ItemPayload
|
after: ItemPayload
|
||||||
): Promise<EditChange[]> {
|
): Promise<EditChange[]> {
|
||||||
const changes: EditChange[] = [];
|
const changes: EditChange[] = [];
|
||||||
const categoryNames = await entityNameMap(client, 'item_categories', [after.categoryId]);
|
|
||||||
const usageNames = await entityNameMap(client, 'item_usages', after.usageId ? [after.usageId] : []);
|
|
||||||
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds);
|
||||||
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
||||||
|
|
||||||
pushChange(changes, 'Name', before.name, after.name);
|
pushChange(changes, 'Name', before.name, after.name);
|
||||||
pushTranslationChanges(changes, before.translations, after.translations, ['name']);
|
pushChange(changes, 'Display ID', String(before.displayId), String(after.displayId));
|
||||||
|
pushChange(changes, 'Description', before.details, after.details);
|
||||||
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
||||||
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
|
||||||
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
||||||
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
|
pushChange(changes, 'Category', before.category.name, systemListNameByKey(itemCategoryOptions, after.categoryKey));
|
||||||
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
|
pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey));
|
||||||
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
||||||
pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
|
pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
|
||||||
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
|
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
|
||||||
@@ -2051,6 +2155,24 @@ async function itemEditChanges(
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ancientArtifactEditChanges(
|
||||||
|
client: DbClient,
|
||||||
|
before: AncientArtifactChangeSource,
|
||||||
|
after: AncientArtifactPayload
|
||||||
|
): Promise<EditChange[]> {
|
||||||
|
const changes: EditChange[] = [];
|
||||||
|
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
||||||
|
|
||||||
|
pushChange(changes, 'Name', before.name, after.name);
|
||||||
|
pushChange(changes, 'Display ID', String(before.displayId), String(after.displayId));
|
||||||
|
pushChange(changes, 'Description', before.details, after.details);
|
||||||
|
pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']);
|
||||||
|
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
||||||
|
pushChange(changes, 'Category', before.category.name, systemListNameByKey(ancientArtifactCategoryOptions, after.categoryKey));
|
||||||
|
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
async function habitatEditChanges(
|
async function habitatEditChanges(
|
||||||
client: DbClient,
|
client: DbClient,
|
||||||
before: HabitatChangeSource,
|
before: HabitatChangeSource,
|
||||||
@@ -2221,8 +2343,6 @@ export async function getOptions(locale = defaultLocale) {
|
|||||||
skills,
|
skills,
|
||||||
environments,
|
environments,
|
||||||
favoriteThings,
|
favoriteThings,
|
||||||
itemCategories,
|
|
||||||
itemUsages,
|
|
||||||
acquisitionMethods,
|
acquisitionMethods,
|
||||||
maps,
|
maps,
|
||||||
lifeCategories,
|
lifeCategories,
|
||||||
@@ -2232,8 +2352,6 @@ export async function getOptions(locale = defaultLocale) {
|
|||||||
skillOptions(locale),
|
skillOptions(locale),
|
||||||
optionSelect('environments', 'environments', locale),
|
optionSelect('environments', 'environments', locale),
|
||||||
optionSelect('favorite_things', 'favorite-things', locale),
|
optionSelect('favorite_things', 'favorite-things', locale),
|
||||||
optionSelect('item_categories', 'item-categories', locale),
|
|
||||||
optionSelect('item_usages', 'item-usages', locale),
|
|
||||||
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
optionSelect('acquisition_methods', 'acquisition-methods', locale),
|
||||||
optionSelect('maps', 'maps', locale),
|
optionSelect('maps', 'maps', locale),
|
||||||
lifeCategoryOptions(locale),
|
lifeCategoryOptions(locale),
|
||||||
@@ -2245,8 +2363,9 @@ export async function getOptions(locale = defaultLocale) {
|
|||||||
skills,
|
skills,
|
||||||
environments,
|
environments,
|
||||||
favoriteThings,
|
favoriteThings,
|
||||||
itemCategories,
|
itemCategories: systemListOptions(itemCategoryOptions, locale),
|
||||||
itemUsages,
|
itemUsages: systemListOptions(itemUsageOptions, locale),
|
||||||
|
ancientArtifactCategories: systemListOptions(ancientArtifactCategoryOptions, locale),
|
||||||
acquisitionMethods,
|
acquisitionMethods,
|
||||||
itemTags: favoriteThings,
|
itemTags: favoriteThings,
|
||||||
maps,
|
maps,
|
||||||
@@ -3379,6 +3498,7 @@ export async function listUserCommentActivities(
|
|||||||
const itemName = localizedName('items', 'i', locale);
|
const itemName = localizedName('items', 'i', locale);
|
||||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||||
const habitatName = localizedName('habitats', 'h', locale);
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
|
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
||||||
const params: unknown[] = [user.id];
|
const params: unknown[] = [user.id];
|
||||||
const outerConditions: string[] = [];
|
const outerConditions: string[] = [];
|
||||||
|
|
||||||
@@ -3444,6 +3564,7 @@ export async function listUserCommentActivities(
|
|||||||
WHEN 'items' THEN ${itemName}
|
WHEN 'items' THEN ${itemName}
|
||||||
WHEN 'recipes' THEN ${recipeItemName}
|
WHEN 'recipes' THEN ${recipeItemName}
|
||||||
WHEN 'habitats' THEN ${habitatName}
|
WHEN 'habitats' THEN ${habitatName}
|
||||||
|
WHEN 'ancient-artifacts' THEN ${artifactName}
|
||||||
ELSE ''
|
ELSE ''
|
||||||
END,
|
END,
|
||||||
''
|
''
|
||||||
@@ -3455,6 +3576,7 @@ export async function listUserCommentActivities(
|
|||||||
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
|
LEFT JOIN recipes r ON edc.entity_type = 'recipes' AND r.id = edc.entity_id
|
||||||
LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
|
LEFT JOIN items recipe_item ON recipe_item.id = r.item_id
|
||||||
LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
|
LEFT JOIN habitats h ON edc.entity_type = 'habitats' AND h.id = edc.entity_id
|
||||||
|
LEFT JOIN ancient_artifacts a ON edc.entity_type = 'ancient-artifacts' AND a.id = edc.entity_id
|
||||||
WHERE edc.created_by_user_id = $1
|
WHERE edc.created_by_user_id = $1
|
||||||
AND edc.deleted_at IS NULL
|
AND edc.deleted_at IS NULL
|
||||||
AND edc.ai_moderation_status = 'approved'
|
AND edc.ai_moderation_status = 'approved'
|
||||||
@@ -4434,6 +4556,11 @@ export async function reorderItems(payload: Record<string, unknown>, userId: num
|
|||||||
return listItems({}, locale);
|
return listItems({}, locale);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderAncientArtifacts(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
|
await reorderContent('ancient-artifacts', payload, userId);
|
||||||
|
return listAncientArtifacts({}, locale);
|
||||||
|
}
|
||||||
|
|
||||||
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
export async function reorderRecipes(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
await reorderContent('recipes', payload, userId);
|
await reorderContent('recipes', payload, userId);
|
||||||
return listRecipes({}, locale);
|
return listRecipes({}, locale);
|
||||||
@@ -4507,7 +4634,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
const habitatName = localizedName('habitats', 'h', locale);
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
const mapName = localizedName('maps', 'm', locale);
|
const mapName = localizedName('maps', 'm', locale);
|
||||||
const itemName = localizedName('items', 'i', locale);
|
const itemName = localizedName('items', 'i', locale);
|
||||||
const categoryName = localizedName('item-categories', 'c', locale);
|
|
||||||
const tagName = localizedName('favorite-things', 'ft', locale);
|
const tagName = localizedName('favorite-things', 'ft', locale);
|
||||||
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
|
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
|
||||||
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
|
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
|
||||||
@@ -4551,16 +4677,15 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
|||||||
i.id,
|
i.id,
|
||||||
${itemName} AS name,
|
${itemName} AS name,
|
||||||
${uploadedImageJson('i.image_path')} AS image,
|
${uploadedImageJson('i.image_path')} AS image,
|
||||||
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
|
||||||
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
|
json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags
|
||||||
FROM pokemon_favorite_things pft
|
FROM pokemon_favorite_things pft
|
||||||
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
|
JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id
|
||||||
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
JOIN favorite_things ft ON ft.id = pft.favorite_thing_id
|
||||||
JOIN items i ON i.id = ift.item_id
|
JOIN items i ON i.id = ift.item_id
|
||||||
JOIN item_categories c ON c.id = i.category_id
|
|
||||||
WHERE pft.pokemon_id = $1
|
WHERE pft.pokemon_id = $1
|
||||||
GROUP BY i.id, i.name, i.image_path, i.sort_order, c.id, c.name, c.sort_order
|
GROUP BY i.id, i.name, i.image_path, i.category_key, i.sort_order
|
||||||
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
|
ORDER BY i.category_key, ${orderByEntity('i')}
|
||||||
`,
|
`,
|
||||||
[id]
|
[id]
|
||||||
),
|
),
|
||||||
@@ -5190,21 +5315,26 @@ export async function deleteHabitat(id: number, userId: number) {
|
|||||||
|
|
||||||
function itemProjection(locale: string): string {
|
function itemProjection(locale: string): string {
|
||||||
const itemName = localizedName('items', 'i', locale);
|
const itemName = localizedName('items', 'i', locale);
|
||||||
const categoryName = localizedName('item-categories', 'c', locale);
|
const itemDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
|
||||||
const usageName = localizedName('item-usages', 'u', locale);
|
|
||||||
const tagName = localizedName('favorite-things', 't', locale);
|
const tagName = localizedName('favorite-things', 't', locale);
|
||||||
|
|
||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
i.id,
|
i.id,
|
||||||
|
i.display_id AS "displayId",
|
||||||
${itemName} AS name,
|
${itemName} AS name,
|
||||||
i.name AS "baseName",
|
i.name AS "baseName",
|
||||||
|
${itemDetails} AS details,
|
||||||
|
i.details AS "baseDetails",
|
||||||
i.is_event_item AS "isEventItem",
|
i.is_event_item AS "isEventItem",
|
||||||
${translationsSelect('items', 'i.id')} AS translations,
|
${translationsSelect('items', 'i.id')} AS translations,
|
||||||
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
||||||
${uploadedImageJson('i.image_path')} AS image,
|
${uploadedImageJson('i.image_path')} AS image,
|
||||||
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
|
||||||
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
|
CASE
|
||||||
|
WHEN i.usage_key IS NULL THEN NULL
|
||||||
|
ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)}
|
||||||
|
END AS usage,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'dyeable', i.dyeable,
|
'dyeable', i.dyeable,
|
||||||
'dualDyeable', i.dual_dyeable,
|
'dualDyeable', i.dual_dyeable,
|
||||||
@@ -5234,8 +5364,6 @@ function itemProjection(locale: string): string {
|
|||||||
)
|
)
|
||||||
END AS recipe
|
END AS recipe
|
||||||
FROM items i
|
FROM items i
|
||||||
JOIN item_categories c ON c.id = i.category_id
|
|
||||||
LEFT JOIN item_usages u ON u.id = i.usage_id
|
|
||||||
LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
|
LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id
|
||||||
LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
|
LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id
|
||||||
LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
|
LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id
|
||||||
@@ -5248,23 +5376,35 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
|||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const categoryId = Number(asString(paramsQuery.categoryId));
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
||||||
const usageId = Number(asString(paramsQuery.usageId));
|
const usageId = Number(asString(paramsQuery.usageId));
|
||||||
|
const isEventItem = asString(paramsQuery.isEventItem);
|
||||||
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
||||||
const search = asString(paramsQuery.search)?.trim();
|
const search = asString(paramsQuery.search)?.trim();
|
||||||
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
|
const recipeOrder = asString(paramsQuery.recipeOrder) === '1';
|
||||||
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
||||||
|
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
||||||
|
: null;
|
||||||
|
const usageOption = Number.isInteger(usageId) && usageId > 0
|
||||||
|
? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired')
|
||||||
|
: null;
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
params.push(`%${search}%`);
|
params.push(`%${search}%`);
|
||||||
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number.isInteger(categoryId) && categoryId > 0) {
|
if (isEventItem === 'true' || isEventItem === 'false') {
|
||||||
params.push(categoryId);
|
params.push(isEventItem === 'true');
|
||||||
conditions.push(`i.category_id = $${params.length}`);
|
conditions.push(`i.is_event_item = $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Number.isInteger(usageId) && usageId > 0) {
|
if (categoryOption) {
|
||||||
params.push(usageId);
|
params.push(categoryOption.key);
|
||||||
conditions.push(`i.usage_id = $${params.length}`);
|
conditions.push(`i.category_key = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usageOption) {
|
||||||
|
params.push(usageOption.key);
|
||||||
|
conditions.push(`i.usage_key = $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagFilter = sqlForRelationFilter(
|
const tagFilter = sqlForRelationFilter(
|
||||||
@@ -5282,8 +5422,8 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
|||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
const orderClause = recipeOrder
|
const orderClause = recipeOrder
|
||||||
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, ${orderByEntity('i')}`
|
? `ORDER BY CASE WHEN item_recipe.id IS NULL THEN 1 ELSE 0 END, item_recipe.sort_order, item_recipe.id, i.display_id, ${orderByEntity('i')}`
|
||||||
: `ORDER BY ${orderByEntity('i')}`;
|
: `ORDER BY i.display_id, ${orderByEntity('i')}`;
|
||||||
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
|
return query(`${itemProjection(locale)} ${whereClause} ${orderClause}`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5295,8 +5435,6 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
|
|
||||||
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
||||||
const resultItemName = localizedName('items', 'result_item', locale);
|
const resultItemName = localizedName('items', 'result_item', locale);
|
||||||
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
|
|
||||||
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
|
|
||||||
const materialItemName = localizedName('items', 'mi', locale);
|
const materialItemName = localizedName('items', 'mi', locale);
|
||||||
const habitatName = localizedName('habitats', 'h', locale);
|
const habitatName = localizedName('habitats', 'h', locale);
|
||||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||||
@@ -5342,18 +5480,17 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
), '[]'::json) AS materials,
|
), '[]'::json) AS materials,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'id', result_item.id,
|
'id', result_item.id,
|
||||||
|
'displayId', result_item.display_id,
|
||||||
'name', ${resultItemName},
|
'name', ${resultItemName},
|
||||||
'image', ${uploadedImageJson('result_item.image_path')},
|
'image', ${uploadedImageJson('result_item.image_path')},
|
||||||
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
|
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
|
||||||
'usage', CASE
|
'usage', CASE
|
||||||
WHEN result_usage.id IS NULL THEN NULL
|
WHEN result_item.usage_key IS NULL THEN NULL
|
||||||
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
|
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
||||||
END
|
END
|
||||||
) AS item
|
) AS item
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
JOIN items result_item ON result_item.id = r.item_id
|
JOIN items result_item ON result_item.id = r.item_id
|
||||||
JOIN item_categories result_category ON result_category.id = result_item.category_id
|
|
||||||
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
|
|
||||||
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
WHERE r.item_id = $1
|
WHERE r.item_id = $1
|
||||||
`,
|
`,
|
||||||
@@ -5442,15 +5579,22 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||||
|
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
|
||||||
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined
|
||||||
? null
|
? null
|
||||||
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
|
: requirePositiveInteger(payload.usageId, 'server.validation.usageRequired');
|
||||||
|
const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
||||||
|
const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
displayId: requirePositiveInteger(payload.displayId, 'server.validation.itemDisplayIdRequired'),
|
||||||
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
||||||
translations: cleanTranslations(payload.translations, ['name']),
|
details: cleanOptionalText(payload.details),
|
||||||
categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
|
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
||||||
|
categoryId,
|
||||||
|
categoryKey: category.key,
|
||||||
usageId,
|
usageId,
|
||||||
|
usageKey: usage?.key ?? null,
|
||||||
dyeable: Boolean(payload.dyeable),
|
dyeable: Boolean(payload.dyeable),
|
||||||
dualDyeable: Boolean(payload.dualDyeable),
|
dualDyeable: Boolean(payload.dualDyeable),
|
||||||
patternEditable: Boolean(payload.patternEditable),
|
patternEditable: Boolean(payload.patternEditable),
|
||||||
@@ -5500,9 +5644,11 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
const result = await client.query<{ id: number }>(
|
const result = await client.query<{ id: number }>(
|
||||||
`
|
`
|
||||||
INSERT INTO items (
|
INSERT INTO items (
|
||||||
|
display_id,
|
||||||
name,
|
name,
|
||||||
category_id,
|
details,
|
||||||
usage_id,
|
category_key,
|
||||||
|
usage_key,
|
||||||
dyeable,
|
dyeable,
|
||||||
dual_dyeable,
|
dual_dyeable,
|
||||||
pattern_editable,
|
pattern_editable,
|
||||||
@@ -5513,13 +5659,15 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
created_by_user_id,
|
created_by_user_id,
|
||||||
updated_by_user_id
|
updated_by_user_id
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
|
cleanPayload.displayId,
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
cleanPayload.categoryId,
|
cleanPayload.details,
|
||||||
cleanPayload.usageId,
|
cleanPayload.categoryKey,
|
||||||
|
cleanPayload.usageKey,
|
||||||
cleanPayload.dyeable,
|
cleanPayload.dyeable,
|
||||||
cleanPayload.dualDyeable,
|
cleanPayload.dualDyeable,
|
||||||
cleanPayload.patternEditable,
|
cleanPayload.patternEditable,
|
||||||
@@ -5533,7 +5681,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
const itemId = result.rows[0].id;
|
const itemId = result.rows[0].id;
|
||||||
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceItemRelations(client, itemId, cleanPayload);
|
await replaceItemRelations(client, itemId, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']);
|
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
|
||||||
await recordEditLog(client, 'items', itemId, 'create', userId);
|
await recordEditLog(client, 'items', itemId, 'create', userId);
|
||||||
return itemId;
|
return itemId;
|
||||||
});
|
});
|
||||||
@@ -5549,23 +5697,27 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
|||||||
const result = await client.query(
|
const result = await client.query(
|
||||||
`
|
`
|
||||||
UPDATE items
|
UPDATE items
|
||||||
SET name = $1,
|
SET display_id = $1,
|
||||||
category_id = $2,
|
name = $2,
|
||||||
usage_id = $3,
|
details = $3,
|
||||||
dyeable = $4,
|
category_key = $4,
|
||||||
dual_dyeable = $5,
|
usage_key = $5,
|
||||||
pattern_editable = $6,
|
dyeable = $6,
|
||||||
no_recipe = $7,
|
dual_dyeable = $7,
|
||||||
is_event_item = $8,
|
pattern_editable = $8,
|
||||||
image_path = $9,
|
no_recipe = $9,
|
||||||
updated_by_user_id = $10,
|
is_event_item = $10,
|
||||||
|
image_path = $11,
|
||||||
|
updated_by_user_id = $12,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $11
|
WHERE id = $13
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
|
cleanPayload.displayId,
|
||||||
cleanPayload.name,
|
cleanPayload.name,
|
||||||
cleanPayload.categoryId,
|
cleanPayload.details,
|
||||||
cleanPayload.usageId,
|
cleanPayload.categoryKey,
|
||||||
|
cleanPayload.usageKey,
|
||||||
cleanPayload.dyeable,
|
cleanPayload.dyeable,
|
||||||
cleanPayload.dualDyeable,
|
cleanPayload.dualDyeable,
|
||||||
cleanPayload.patternEditable,
|
cleanPayload.patternEditable,
|
||||||
@@ -5581,7 +5733,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
|||||||
}
|
}
|
||||||
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceItemRelations(client, id, cleanPayload);
|
await replaceItemRelations(client, id, cleanPayload);
|
||||||
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']);
|
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
|
||||||
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
||||||
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
||||||
return true;
|
return true;
|
||||||
@@ -5603,16 +5755,211 @@ export async function deleteItem(id: number, userId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ancientArtifactProjection(locale: string): string {
|
||||||
|
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
||||||
|
const artifactDetails = localizedField('ancient-artifacts', 'a.id', 'a.details', 'details', locale);
|
||||||
|
const tagName = localizedName('favorite-things', 't', locale);
|
||||||
|
|
||||||
|
return `
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.display_id AS "displayId",
|
||||||
|
${artifactName} AS name,
|
||||||
|
a.name AS "baseName",
|
||||||
|
${artifactDetails} AS details,
|
||||||
|
a.details AS "baseDetails",
|
||||||
|
${translationsSelect('ancient-artifacts', 'a.id')} AS translations,
|
||||||
|
${systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale)} AS category,
|
||||||
|
${uploadedImageJson('a.image_path')} AS image,
|
||||||
|
COALESCE((
|
||||||
|
SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${orderByEntity('t')})
|
||||||
|
FROM ancient_artifact_favorite_things aaft
|
||||||
|
JOIN favorite_things t ON t.id = aaft.favorite_thing_id
|
||||||
|
WHERE aaft.ancient_artifact_id = a.id
|
||||||
|
), '[]'::json) AS tags,
|
||||||
|
${auditSelect('a', 'artifact_created_user', 'artifact_updated_user')}
|
||||||
|
FROM ancient_artifacts a
|
||||||
|
${auditJoins('a', 'artifact_created_user', 'artifact_updated_user')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const search = asString(paramsQuery.search)?.trim();
|
||||||
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
||||||
|
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
||||||
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
||||||
|
? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
params.push(`%${search}%`);
|
||||||
|
conditions.push(`${localizedName('ancient-artifacts', 'a', locale)} ILIKE $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryOption) {
|
||||||
|
params.push(categoryOption.key);
|
||||||
|
conditions.push(`a.category_key = $${params.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagFilter = sqlForRelationFilter(
|
||||||
|
tagIds,
|
||||||
|
'any',
|
||||||
|
'ancient_artifact_favorite_things',
|
||||||
|
'ancient_artifact_id',
|
||||||
|
'favorite_thing_id',
|
||||||
|
'a.id',
|
||||||
|
params
|
||||||
|
);
|
||||||
|
if (tagFilter) {
|
||||||
|
conditions.push(tagFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY a.display_id, ${orderByEntity('a')}`, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAncientArtifact(id: number, locale = defaultLocale) {
|
||||||
|
const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE a.id = $1`, [id]);
|
||||||
|
if (!artifact) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editHistory = await getEditHistory('ancient-artifacts', id);
|
||||||
|
const imageHistory = await listEntityImageUploads('ancient-artifacts', id);
|
||||||
|
return { ...artifact, editHistory, imageHistory };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanAncientArtifactPayload(payload: Record<string, unknown>): AncientArtifactPayload {
|
||||||
|
const categoryId = requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired');
|
||||||
|
const category = systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired');
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayId: requirePositiveInteger(payload.displayId, 'server.validation.artifactDisplayIdRequired'),
|
||||||
|
name: cleanName(payload.name, 'server.validation.artifactNameRequired'),
|
||||||
|
details: cleanOptionalText(payload.details),
|
||||||
|
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
||||||
|
categoryId,
|
||||||
|
categoryKey: category.key,
|
||||||
|
tagIds: cleanIds(payload.tagIds),
|
||||||
|
imagePath: cleanUploadImagePath(payload.imagePath, 'ancient-artifacts')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceAncientArtifactRelations(client: DbClient, artifactId: number, payload: AncientArtifactPayload): Promise<void> {
|
||||||
|
await client.query('DELETE FROM ancient_artifact_favorite_things WHERE ancient_artifact_id = $1', [artifactId]);
|
||||||
|
|
||||||
|
for (const tagId of payload.tagIds) {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO ancient_artifact_favorite_things (ancient_artifact_id, favorite_thing_id) VALUES ($1, $2)',
|
||||||
|
[artifactId, tagId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAncientArtifact(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
|
const cleanPayload = cleanAncientArtifactPayload(payload);
|
||||||
|
|
||||||
|
const id = await withTransaction(async (client) => {
|
||||||
|
const sortOrder = await nextSortOrder(client, 'ancient_artifacts');
|
||||||
|
const result = await client.query<{ id: number }>(
|
||||||
|
`
|
||||||
|
INSERT INTO ancient_artifacts (
|
||||||
|
display_id,
|
||||||
|
name,
|
||||||
|
details,
|
||||||
|
category_key,
|
||||||
|
image_path,
|
||||||
|
sort_order,
|
||||||
|
created_by_user_id,
|
||||||
|
updated_by_user_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $7)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
cleanPayload.displayId,
|
||||||
|
cleanPayload.name,
|
||||||
|
cleanPayload.details,
|
||||||
|
cleanPayload.categoryKey,
|
||||||
|
cleanPayload.imagePath,
|
||||||
|
sortOrder,
|
||||||
|
userId
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const artifactId = result.rows[0].id;
|
||||||
|
await linkEntityImageUpload(client, 'ancient-artifacts', artifactId, cleanPayload.imagePath, cleanPayload.name);
|
||||||
|
await replaceAncientArtifactRelations(client, artifactId, cleanPayload);
|
||||||
|
await replaceEntityTranslations(client, 'ancient-artifacts', artifactId, cleanPayload.translations, ['name', 'details']);
|
||||||
|
await recordEditLog(client, 'ancient-artifacts', artifactId, 'create', userId);
|
||||||
|
return artifactId;
|
||||||
|
});
|
||||||
|
|
||||||
|
return getAncientArtifact(id, locale);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAncientArtifact(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
|
const cleanPayload = cleanAncientArtifactPayload(payload);
|
||||||
|
const before = await getAncientArtifact(id, defaultLocale);
|
||||||
|
|
||||||
|
const updated = await withTransaction(async (client) => {
|
||||||
|
const result = await client.query(
|
||||||
|
`
|
||||||
|
UPDATE ancient_artifacts
|
||||||
|
SET display_id = $1,
|
||||||
|
name = $2,
|
||||||
|
details = $3,
|
||||||
|
category_key = $4,
|
||||||
|
image_path = $5,
|
||||||
|
updated_by_user_id = $6,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $7
|
||||||
|
`,
|
||||||
|
[cleanPayload.displayId, cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await linkEntityImageUpload(client, 'ancient-artifacts', id, cleanPayload.imagePath, cleanPayload.name);
|
||||||
|
await replaceAncientArtifactRelations(client, id, cleanPayload);
|
||||||
|
await replaceEntityTranslations(client, 'ancient-artifacts', id, cleanPayload.translations, ['name', 'details']);
|
||||||
|
const changes = before ? await ancientArtifactEditChanges(client, before as unknown as AncientArtifactChangeSource, cleanPayload) : [];
|
||||||
|
await recordEditLog(client, 'ancient-artifacts', id, 'update', userId, changes);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated ? getAncientArtifact(id, locale) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAncientArtifact(id: number, userId: number) {
|
||||||
|
return withTransaction(async (client) => {
|
||||||
|
const result = await client.query<{ id: number }>('DELETE FROM ancient_artifacts WHERE id = $1 RETURNING id', [id]);
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id);
|
||||||
|
await deleteEntityTranslations(client, 'ancient-artifacts', id);
|
||||||
|
await recordEditLog(client, 'ancient-artifacts', id, 'delete', userId);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) {
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const categoryId = Number(asString(paramsQuery.categoryId));
|
const categoryId = Number(asString(paramsQuery.categoryId));
|
||||||
|
const categoryOption = Number.isInteger(categoryId) && categoryId > 0
|
||||||
|
? systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired')
|
||||||
|
: null;
|
||||||
const resultItemName = localizedName('items', 'result_item', locale);
|
const resultItemName = localizedName('items', 'result_item', locale);
|
||||||
const materialItemName = localizedName('items', 'i', locale);
|
const materialItemName = localizedName('items', 'i', locale);
|
||||||
|
|
||||||
if (Number.isInteger(categoryId) && categoryId > 0) {
|
if (categoryOption) {
|
||||||
params.push(categoryId);
|
params.push(categoryOption.key);
|
||||||
conditions.push(`result_item.category_id = $${params.length}`);
|
conditions.push(`result_item.category_key = $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
@@ -5637,8 +5984,6 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul
|
|||||||
|
|
||||||
export async function getRecipe(id: number, locale = defaultLocale) {
|
export async function getRecipe(id: number, locale = defaultLocale) {
|
||||||
const resultItemName = localizedName('items', 'result_item', locale);
|
const resultItemName = localizedName('items', 'result_item', locale);
|
||||||
const resultItemCategoryName = localizedName('item-categories', 'result_category', locale);
|
|
||||||
const resultItemUsageName = localizedName('item-usages', 'result_usage', locale);
|
|
||||||
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale);
|
||||||
const materialItemName = localizedName('items', 'i', locale);
|
const materialItemName = localizedName('items', 'i', locale);
|
||||||
|
|
||||||
@@ -5670,18 +6015,17 @@ export async function getRecipe(id: number, locale = defaultLocale) {
|
|||||||
), '[]'::json) AS materials,
|
), '[]'::json) AS materials,
|
||||||
json_build_object(
|
json_build_object(
|
||||||
'id', result_item.id,
|
'id', result_item.id,
|
||||||
|
'displayId', result_item.display_id,
|
||||||
'name', ${resultItemName},
|
'name', ${resultItemName},
|
||||||
'image', ${uploadedImageJson('result_item.image_path')},
|
'image', ${uploadedImageJson('result_item.image_path')},
|
||||||
'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}),
|
'category', ${systemListJsonSql('result_item.category_key', itemCategoryOptions, locale)},
|
||||||
'usage', CASE
|
'usage', CASE
|
||||||
WHEN result_usage.id IS NULL THEN NULL
|
WHEN result_item.usage_key IS NULL THEN NULL
|
||||||
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
|
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
||||||
END
|
END
|
||||||
) AS item
|
) AS item
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
JOIN items result_item ON result_item.id = r.item_id
|
JOIN items result_item ON result_item.id = r.item_id
|
||||||
JOIN item_categories result_category ON result_category.id = result_item.category_id
|
|
||||||
LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id
|
|
||||||
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')}
|
||||||
WHERE r.id = $1
|
WHERE r.id = $1
|
||||||
`,
|
`,
|
||||||
@@ -5791,11 +6135,12 @@ export async function deleteRecipe(id: number, userId: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataToolScopes = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
|
const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
|
||||||
const dataToolMainTables: Record<DataToolScope, string> = {
|
const dataToolMainTables: Record<DataToolScope, string> = {
|
||||||
pokemon: 'pokemon',
|
pokemon: 'pokemon',
|
||||||
habitats: 'habitats',
|
habitats: 'habitats',
|
||||||
items: 'items',
|
items: 'items',
|
||||||
|
artifacts: 'ancient_artifacts',
|
||||||
recipes: 'recipes',
|
recipes: 'recipes',
|
||||||
checklist: 'daily_checklist_items'
|
checklist: 'daily_checklist_items'
|
||||||
};
|
};
|
||||||
@@ -5839,9 +6184,11 @@ const dataToolColumns = {
|
|||||||
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
|
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
|
||||||
items: [
|
items: [
|
||||||
'id',
|
'id',
|
||||||
|
'display_id',
|
||||||
'name',
|
'name',
|
||||||
'category_id',
|
'details',
|
||||||
'usage_id',
|
'category_key',
|
||||||
|
'usage_key',
|
||||||
'dyeable',
|
'dyeable',
|
||||||
'dual_dyeable',
|
'dual_dyeable',
|
||||||
'pattern_editable',
|
'pattern_editable',
|
||||||
@@ -5856,6 +6203,20 @@ const dataToolColumns = {
|
|||||||
],
|
],
|
||||||
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
||||||
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
|
itemFavoriteThings: ['item_id', 'favorite_thing_id'],
|
||||||
|
artifactFavoriteThings: ['ancient_artifact_id', 'favorite_thing_id'],
|
||||||
|
artifacts: [
|
||||||
|
'id',
|
||||||
|
'display_id',
|
||||||
|
'name',
|
||||||
|
'details',
|
||||||
|
'category_key',
|
||||||
|
'image_path',
|
||||||
|
'sort_order',
|
||||||
|
'created_by_user_id',
|
||||||
|
'updated_by_user_id',
|
||||||
|
'created_at',
|
||||||
|
'updated_at'
|
||||||
|
],
|
||||||
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
|
||||||
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
||||||
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
||||||
@@ -5965,8 +6326,20 @@ async function tableRows(client: DbClient, sql: string, params: unknown[] = []):
|
|||||||
return result.rows;
|
return result.rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeImportValue(column: string, value: unknown): unknown {
|
function normalizeImportValue(column: string, value: unknown, row: Record<string, unknown>): unknown {
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
|
if (column === 'display_id' && typeof row.id === 'number') {
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
if (column === 'details') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (column === 'image_path') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (column === 'category_key') {
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (column === 'changes' && typeof value !== 'string') {
|
if (column === 'changes' && typeof value !== 'string') {
|
||||||
@@ -5978,7 +6351,7 @@ function normalizeImportValue(column: string, value: unknown): unknown {
|
|||||||
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
|
||||||
const values = columns.map((column) => normalizeImportValue(column, row[column]));
|
const values = columns.map((column) => normalizeImportValue(column, row[column], row));
|
||||||
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
|
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5995,7 +6368,16 @@ async function resetIdentity(client: DbClient, tableName: string): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function resetDataToolIdentities(client: DbClient): Promise<void> {
|
async function resetDataToolIdentities(client: DbClient): Promise<void> {
|
||||||
for (const tableName of ['daily_checklist_items', 'items', 'recipes', 'habitats', 'wiki_edit_logs', 'entity_image_uploads', 'entity_discussion_comments']) {
|
for (const tableName of [
|
||||||
|
'daily_checklist_items',
|
||||||
|
'items',
|
||||||
|
'ancient_artifacts',
|
||||||
|
'recipes',
|
||||||
|
'habitats',
|
||||||
|
'wiki_edit_logs',
|
||||||
|
'entity_image_uploads',
|
||||||
|
'entity_discussion_comments'
|
||||||
|
]) {
|
||||||
await resetIdentity(client, tableName);
|
await resetIdentity(client, tableName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6024,6 +6406,12 @@ async function wipeItemsData(client: DbClient): Promise<void> {
|
|||||||
await client.query('DELETE FROM items');
|
await client.query('DELETE FROM items');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function wipeAncientArtifactsData(client: DbClient): Promise<void> {
|
||||||
|
await deleteGenericEntityRows(client, ['ancient-artifacts']);
|
||||||
|
await client.query('DELETE FROM ancient_artifact_favorite_things');
|
||||||
|
await client.query('DELETE FROM ancient_artifacts');
|
||||||
|
}
|
||||||
|
|
||||||
async function wipePokemonData(client: DbClient): Promise<void> {
|
async function wipePokemonData(client: DbClient): Promise<void> {
|
||||||
await deleteGenericEntityRows(client, ['pokemon']);
|
await deleteGenericEntityRows(client, ['pokemon']);
|
||||||
await client.query('DELETE FROM habitat_pokemon');
|
await client.query('DELETE FROM habitat_pokemon');
|
||||||
@@ -6053,6 +6441,9 @@ async function wipeDataToolScopes(client: DbClient, scopes: DataToolScope[], res
|
|||||||
} else if (scopeSet.has('recipes')) {
|
} else if (scopeSet.has('recipes')) {
|
||||||
await wipeRecipesData(client);
|
await wipeRecipesData(client);
|
||||||
}
|
}
|
||||||
|
if (scopeSet.has('artifacts')) {
|
||||||
|
await wipeAncientArtifactsData(client);
|
||||||
|
}
|
||||||
if (scopeSet.has('pokemon')) {
|
if (scopeSet.has('pokemon')) {
|
||||||
await wipePokemonData(client);
|
await wipePokemonData(client);
|
||||||
}
|
}
|
||||||
@@ -6114,7 +6505,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
|
|||||||
|
|
||||||
if (scope === 'items') {
|
if (scope === 'items') {
|
||||||
return {
|
return {
|
||||||
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
|
items: await tableRows(client, 'SELECT * FROM items ORDER BY display_id, sort_order, id'),
|
||||||
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
|
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
|
||||||
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
|
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
|
||||||
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
|
||||||
@@ -6123,6 +6514,17 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (scope === 'artifacts') {
|
||||||
|
return {
|
||||||
|
artifacts: await tableRows(client, 'SELECT * FROM ancient_artifacts ORDER BY display_id, sort_order, id'),
|
||||||
|
artifactFavoriteThings: await tableRows(
|
||||||
|
client,
|
||||||
|
'SELECT * FROM ancient_artifact_favorite_things ORDER BY ancient_artifact_id, favorite_thing_id'
|
||||||
|
),
|
||||||
|
...(await exportGenericScopeData(client, 'ancient-artifacts', true))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (scope === 'recipes') {
|
if (scope === 'recipes') {
|
||||||
return {
|
return {
|
||||||
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
|
recipes: await tableRows(client, 'SELECT * FROM recipes ORDER BY sort_order, id'),
|
||||||
@@ -6145,12 +6547,14 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
|
|||||||
|
|
||||||
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
||||||
const itemData = bundle.data.items;
|
const itemData = bundle.data.items;
|
||||||
|
const artifactData = bundle.data.artifacts;
|
||||||
const pokemonData = bundle.data.pokemon;
|
const pokemonData = bundle.data.pokemon;
|
||||||
const habitatData = bundle.data.habitats;
|
const habitatData = bundle.data.habitats;
|
||||||
const checklistData = bundle.data.checklist;
|
const checklistData = bundle.data.checklist;
|
||||||
const recipeData = bundle.data.recipes;
|
const recipeData = bundle.data.recipes;
|
||||||
|
|
||||||
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
|
await insertRows(client, 'items', dataToolColumns.items, dataToolTableRows(itemData, 'items'));
|
||||||
|
await insertRows(client, 'ancient_artifacts', dataToolColumns.artifacts, dataToolTableRows(artifactData, 'artifacts'));
|
||||||
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
|
await insertRows(client, 'pokemon', dataToolColumns.pokemon, dataToolTableRows(pokemonData, 'pokemon'));
|
||||||
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
await insertRows(client, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
||||||
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
|
await insertRows(client, 'daily_checklist_items', dataToolColumns.checklist, dataToolTableRows(checklistData, 'checklist'));
|
||||||
@@ -6159,6 +6563,7 @@ async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): P
|
|||||||
|
|
||||||
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle): Promise<void> {
|
||||||
const itemData = bundle.data.items;
|
const itemData = bundle.data.items;
|
||||||
|
const artifactData = bundle.data.artifacts;
|
||||||
const pokemonData = bundle.data.pokemon;
|
const pokemonData = bundle.data.pokemon;
|
||||||
const habitatData = bundle.data.habitats;
|
const habitatData = bundle.data.habitats;
|
||||||
const recipeData = bundle.data.recipes;
|
const recipeData = bundle.data.recipes;
|
||||||
@@ -6168,6 +6573,12 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle
|
|||||||
|
|
||||||
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
|
await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
|
||||||
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
|
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
|
||||||
|
await insertRows(
|
||||||
|
client,
|
||||||
|
'ancient_artifact_favorite_things',
|
||||||
|
dataToolColumns.artifactFavoriteThings,
|
||||||
|
dataToolTableRows(artifactData, 'artifactFavoriteThings')
|
||||||
|
);
|
||||||
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
|
await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
|
||||||
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
||||||
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
import { initializeDatabase, pool } from './db.ts';
|
import { initializeDatabase, pool } from './db.ts';
|
||||||
import {
|
import {
|
||||||
cleanLocale,
|
cleanLocale,
|
||||||
|
createAncientArtifact,
|
||||||
createConfig,
|
createConfig,
|
||||||
createDailyChecklistItem,
|
createDailyChecklistItem,
|
||||||
createEntityDiscussionComment,
|
createEntityDiscussionComment,
|
||||||
@@ -48,6 +49,7 @@ import {
|
|||||||
createPokemon,
|
createPokemon,
|
||||||
createRecipe,
|
createRecipe,
|
||||||
deleteConfig,
|
deleteConfig,
|
||||||
|
deleteAncientArtifact,
|
||||||
deleteDailyChecklistItem,
|
deleteDailyChecklistItem,
|
||||||
deleteEntityDiscussionComment,
|
deleteEntityDiscussionComment,
|
||||||
deleteHabitat,
|
deleteHabitat,
|
||||||
@@ -63,6 +65,7 @@ import {
|
|||||||
fetchPokemonData,
|
fetchPokemonData,
|
||||||
fetchPokemonImageOptions,
|
fetchPokemonImageOptions,
|
||||||
getAdminDataToolsSummary,
|
getAdminDataToolsSummary,
|
||||||
|
getAncientArtifact,
|
||||||
getHabitat,
|
getHabitat,
|
||||||
getItem,
|
getItem,
|
||||||
getOptions,
|
getOptions,
|
||||||
@@ -71,6 +74,7 @@ import {
|
|||||||
getRecipe,
|
getRecipe,
|
||||||
importAdminData,
|
importAdminData,
|
||||||
isConfigType,
|
isConfigType,
|
||||||
|
listAncientArtifacts,
|
||||||
listEntityDiscussionComments,
|
listEntityDiscussionComments,
|
||||||
listConfig,
|
listConfig,
|
||||||
listDailyChecklistItems,
|
listDailyChecklistItems,
|
||||||
@@ -86,6 +90,7 @@ import {
|
|||||||
listUserLifePosts,
|
listUserLifePosts,
|
||||||
listUserReactionActivities,
|
listUserReactionActivities,
|
||||||
reorderConfig,
|
reorderConfig,
|
||||||
|
reorderAncientArtifacts,
|
||||||
reorderDailyChecklistItems,
|
reorderDailyChecklistItems,
|
||||||
reorderHabitats,
|
reorderHabitats,
|
||||||
reorderItems,
|
reorderItems,
|
||||||
@@ -98,6 +103,7 @@ import {
|
|||||||
setLifePostRating,
|
setLifePostRating,
|
||||||
setLifePostReaction,
|
setLifePostReaction,
|
||||||
updateConfig,
|
updateConfig,
|
||||||
|
updateAncientArtifact,
|
||||||
updateDailyChecklistItem,
|
updateDailyChecklistItem,
|
||||||
updateHabitat,
|
updateHabitat,
|
||||||
updateItem,
|
updateItem,
|
||||||
@@ -1504,7 +1510,13 @@ app.post('/api/uploads/:entityType', async (request, reply) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const permissionKey =
|
const permissionKey =
|
||||||
entityType === 'pokemon' ? 'pokemon.upload' : entityType === 'items' ? 'items.upload' : 'habitats.upload';
|
entityType === 'pokemon'
|
||||||
|
? 'pokemon.upload'
|
||||||
|
: entityType === 'items'
|
||||||
|
? 'items.upload'
|
||||||
|
: entityType === 'habitats'
|
||||||
|
? 'habitats.upload'
|
||||||
|
: 'ancient-artifacts.upload';
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, permissionKey, 'upload');
|
const user = await requirePermissionWithRateLimits(request, reply, permissionKey, 'upload');
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
@@ -1643,6 +1655,53 @@ app.delete('/api/items/:id', async (request, reply) => {
|
|||||||
return deleted ? reply.code(204).send() : notFound(reply, request);
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/ancient-artifacts', async (request) =>
|
||||||
|
listAncientArtifacts(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/api/ancient-artifacts/:id', async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const artifact = await getAncientArtifact(Number(id), requestLocale(request));
|
||||||
|
|
||||||
|
if (!artifact) {
|
||||||
|
return notFound(reply, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifact;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/ancient-artifacts', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.create', 'wikiWrite');
|
||||||
|
return user
|
||||||
|
? reply.code(201).send(await createAncientArtifact(request.body as Record<string, unknown>, user.id, requestLocale(request)))
|
||||||
|
: undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/ancient-artifacts/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.update', 'wikiWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const artifact = await updateAncientArtifact(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
|
||||||
|
|
||||||
|
if (!artifact) {
|
||||||
|
return notFound(reply, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifact;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/ancient-artifacts/:id', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.delete', 'wikiWrite');
|
||||||
|
if (!user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const deleted = await deleteAncientArtifact(Number(id), user.id);
|
||||||
|
return deleted ? reply.code(204).send() : notFound(reply, request);
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/recipes', async (request) =>
|
app.get('/api/recipes', async (request) =>
|
||||||
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
|
||||||
);
|
);
|
||||||
@@ -1739,6 +1798,11 @@ app.put('/api/admin/items/order', async (request, reply) => {
|
|||||||
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
return user ? reorderItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/admin/ancient-artifacts/order', async (request, reply) => {
|
||||||
|
const user = await requirePermissionWithRateLimits(request, reply, 'ancient-artifacts.order', 'wikiWrite');
|
||||||
|
return user ? reorderAncientArtifacts(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
app.put('/api/admin/recipes/order', async (request, reply) => {
|
app.put('/api/admin/recipes/order', async (request, reply) => {
|
||||||
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.order', 'wikiWrite');
|
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.order', 'wikiWrite');
|
||||||
return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
return user ? reorderRecipes(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { PoolClient } from 'pg';
|
|||||||
import type { AuthUser } from './auth.ts';
|
import type { AuthUser } from './auth.ts';
|
||||||
import { query, queryOne } from './db.ts';
|
import { query, queryOne } from './db.ts';
|
||||||
|
|
||||||
export type UploadEntityType = 'pokemon' | 'items' | 'habitats';
|
export type UploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
|
||||||
|
|
||||||
export type EntityImageUpload = {
|
export type EntityImageUpload = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -26,7 +26,7 @@ type MultipartField = {
|
|||||||
value?: unknown;
|
value?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats']);
|
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats', 'ancient-artifacts']);
|
||||||
const imageMimeTypes = new Map([
|
const imageMimeTypes = new Map([
|
||||||
['image/png', '.png'],
|
['image/png', '.png'],
|
||||||
['image/jpeg', '.jpg'],
|
['image/jpeg', '.jpg'],
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||||
/>
|
/>
|
||||||
<meta name="robots" content="index, follow" />
|
<meta name="robots" content="index, follow" />
|
||||||
<meta name="theme-color" content="#6ccf32" />
|
<meta name="theme-color" content="#6ccf32" />
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||||
/>
|
/>
|
||||||
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
|
<meta property="og:url" content="%POKOPIA_SITE_URL%/pokemon" />
|
||||||
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
<meta property="og:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
<meta name="twitter:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
content="Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia."
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
<meta name="twitter:image" content="%POKOPIA_SITE_URL%/seo/pokopia-hero.jpg" />
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AppShell from './components/AppShell.vue';
|
|||||||
import {
|
import {
|
||||||
iconAction,
|
iconAction,
|
||||||
iconAdmin,
|
iconAdmin,
|
||||||
|
iconArtifact,
|
||||||
iconAutomation,
|
iconAutomation,
|
||||||
iconChecklist,
|
iconChecklist,
|
||||||
iconClothes,
|
iconClothes,
|
||||||
@@ -49,6 +50,8 @@ const navItems = computed(() => {
|
|||||||
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
||||||
{ label: t('nav.eventHabitats'), to: '/event-habitats', icon: iconEvent },
|
{ label: t('nav.eventHabitats'), to: '/event-habitats', icon: iconEvent },
|
||||||
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
{ label: t('nav.items'), to: '/items', icon: iconItem },
|
||||||
|
{ label: t('nav.eventItems'), to: '/event-items', icon: iconEvent },
|
||||||
|
{ label: t('nav.ancientArtifacts'), to: '/ancient-artifacts', icon: iconArtifact },
|
||||||
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
|
||||||
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
|
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
|
||||||
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
|
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
|
||||||
|
|||||||
@@ -16,11 +16,13 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
标题: 'pages.checklist.task',
|
标题: 'pages.checklist.task',
|
||||||
'Pokemon ID': 'pages.pokemon.id',
|
'Pokemon ID': 'pages.pokemon.id',
|
||||||
'Pokopia ID': 'pages.pokemon.id',
|
'Pokopia ID': 'pages.pokemon.id',
|
||||||
|
'Display ID': 'pages.items.displayId',
|
||||||
'Event item': 'common.eventItem',
|
'Event item': 'common.eventItem',
|
||||||
'Event Pokemon': 'pages.pokemon.eventItem',
|
'Event Pokemon': 'pages.pokemon.eventItem',
|
||||||
'Event Habitat': 'pages.habitats.eventItem',
|
'Event Habitat': 'pages.habitats.eventItem',
|
||||||
Genus: 'pages.pokemon.genus',
|
Genus: 'pages.pokemon.genus',
|
||||||
Details: 'pages.pokemon.details',
|
Details: 'pages.pokemon.details',
|
||||||
|
Description: 'pages.items.description',
|
||||||
介绍: 'pages.pokemon.details',
|
介绍: 'pages.pokemon.details',
|
||||||
Image: 'pages.pokemon.image',
|
Image: 'pages.pokemon.image',
|
||||||
图片: 'pages.pokemon.image',
|
图片: 'pages.pokemon.image',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type AppIcon = string;
|
|||||||
export const iconAdd: AppIcon = 'mdi:plus';
|
export const iconAdd: AppIcon = 'mdi:plus';
|
||||||
export const iconAdmin: AppIcon = 'mdi:tune-variant';
|
export const iconAdmin: AppIcon = 'mdi:tune-variant';
|
||||||
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
|
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
|
||||||
|
export const iconArtifact: AppIcon = 'mdi:diamond-stone';
|
||||||
export const iconAutomation: AppIcon = 'mdi:factory';
|
export const iconAutomation: AppIcon = 'mdi:factory';
|
||||||
export const iconBack: AppIcon = 'mdi:arrow-left';
|
export const iconBack: AppIcon = 'mdi:arrow-left';
|
||||||
export const iconCancel: AppIcon = 'mdi:close';
|
export const iconCancel: AppIcon = 'mdi:close';
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import HabitatList from '../views/HabitatList.vue';
|
|||||||
import HabitatDetail from '../views/HabitatDetail.vue';
|
import HabitatDetail from '../views/HabitatDetail.vue';
|
||||||
import ItemsList from '../views/ItemsList.vue';
|
import ItemsList from '../views/ItemsList.vue';
|
||||||
import ItemDetail from '../views/ItemDetail.vue';
|
import ItemDetail from '../views/ItemDetail.vue';
|
||||||
|
import AncientArtifactList from '../views/AncientArtifactList.vue';
|
||||||
|
import AncientArtifactDetail from '../views/AncientArtifactDetail.vue';
|
||||||
import RecipeList from '../views/RecipeList.vue';
|
import RecipeList from '../views/RecipeList.vue';
|
||||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||||
@@ -133,17 +135,42 @@ export const router = createRouter({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
|
{ path: '/habitats/:id', name: 'habitat-detail', component: HabitatDetail, meta: { seo: seo({ titleKey: 'pages.habitats.detailKicker', descriptionKey: 'pages.habitats.subtitle' }) } },
|
||||||
{ path: '/items', name: 'item-list', component: ItemsList, meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) } },
|
{
|
||||||
|
path: '/items',
|
||||||
|
name: 'item-list',
|
||||||
|
component: ItemsList,
|
||||||
|
props: { eventOnly: false },
|
||||||
|
meta: { seo: seo({ titleKey: 'pages.items.title', descriptionKey: 'pages.items.subtitle' }) }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/items/new',
|
path: '/items/new',
|
||||||
name: 'item-new',
|
name: 'item-new',
|
||||||
component: ItemsList,
|
component: ItemsList,
|
||||||
|
props: { eventOnly: false },
|
||||||
meta: {
|
meta: {
|
||||||
requiredPermission: 'items.create',
|
requiredPermission: 'items.create',
|
||||||
editorModal: true,
|
editorModal: true,
|
||||||
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
|
seo: seo({ titleKey: 'pages.items.newTitle', descriptionKey: 'pages.items.editSubtitle', canonicalPath: '/items', noindex: true })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/event-items',
|
||||||
|
name: 'event-item-list',
|
||||||
|
component: ItemsList,
|
||||||
|
props: { eventOnly: true },
|
||||||
|
meta: { seo: seo({ titleKey: 'pages.eventItems.title', descriptionKey: 'pages.eventItems.subtitle', canonicalPath: '/event-items' }) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/event-items/new',
|
||||||
|
name: 'event-item-new',
|
||||||
|
component: ItemsList,
|
||||||
|
props: { eventOnly: true },
|
||||||
|
meta: {
|
||||||
|
requiredPermission: 'items.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: seo({ titleKey: 'pages.eventItems.newTitle', descriptionKey: 'pages.eventItems.editSubtitle', canonicalPath: '/event-items', noindex: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/items/:id/edit',
|
path: '/items/:id/edit',
|
||||||
name: 'item-edit',
|
name: 'item-edit',
|
||||||
@@ -160,6 +187,48 @@ export const router = createRouter({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
|
{ path: '/items/:id', name: 'item-detail', component: ItemDetail, meta: { seo: seo({ titleKey: 'pages.items.detailKicker', descriptionKey: 'pages.items.subtitle' }) } },
|
||||||
|
{
|
||||||
|
path: '/ancient-artifacts',
|
||||||
|
name: 'ancient-artifact-list',
|
||||||
|
component: AncientArtifactList,
|
||||||
|
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.title', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ancient-artifacts/new',
|
||||||
|
name: 'ancient-artifact-new',
|
||||||
|
component: AncientArtifactList,
|
||||||
|
meta: {
|
||||||
|
requiredPermission: 'ancient-artifacts.create',
|
||||||
|
editorModal: true,
|
||||||
|
seo: seo({
|
||||||
|
titleKey: 'pages.ancientArtifacts.newTitle',
|
||||||
|
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||||
|
canonicalPath: '/ancient-artifacts',
|
||||||
|
noindex: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ancient-artifacts/:id/edit',
|
||||||
|
name: 'ancient-artifact-edit',
|
||||||
|
component: AncientArtifactDetail,
|
||||||
|
meta: {
|
||||||
|
requiredPermission: 'ancient-artifacts.update',
|
||||||
|
editorModal: true,
|
||||||
|
seo: seo({
|
||||||
|
titleKey: 'pages.ancientArtifacts.editKicker',
|
||||||
|
descriptionKey: 'pages.ancientArtifacts.editSubtitle',
|
||||||
|
canonicalPath: (route) => `/ancient-artifacts/${String(route.params.id)}`,
|
||||||
|
noindex: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ancient-artifacts/:id',
|
||||||
|
name: 'ancient-artifact-detail',
|
||||||
|
component: AncientArtifactDetail,
|
||||||
|
meta: { seo: seo({ titleKey: 'pages.ancientArtifacts.detailKicker', descriptionKey: 'pages.ancientArtifacts.subtitle' }) }
|
||||||
|
},
|
||||||
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
|
{ path: '/recipes', name: 'recipe-list', component: RecipeList, meta: { seo: seo({ titleKey: 'pages.recipes.title', descriptionKey: 'pages.recipes.subtitle' }) } },
|
||||||
{
|
{
|
||||||
path: '/recipes/new',
|
path: '/recipes/new',
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export interface EntityImageUpload extends EntityImage {
|
|||||||
uploadedBy: UserSummary | null;
|
uploadedBy: UserSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats';
|
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
|
||||||
|
|
||||||
export interface PokemonImage extends EntityImage {
|
export interface PokemonImage extends EntityImage {
|
||||||
style: string;
|
style: string;
|
||||||
@@ -246,6 +246,7 @@ export interface HabitatUsage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RecipeResultItem extends NamedEntity {
|
export interface RecipeResultItem extends NamedEntity {
|
||||||
|
displayId: number;
|
||||||
image?: EntityImage | null;
|
image?: EntityImage | null;
|
||||||
category?: NamedEntity;
|
category?: NamedEntity;
|
||||||
usage?: NamedEntity | null;
|
usage?: NamedEntity | null;
|
||||||
@@ -253,8 +254,11 @@ export interface RecipeResultItem extends NamedEntity {
|
|||||||
|
|
||||||
export interface Item extends EditInfo {
|
export interface Item extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
|
displayId: number;
|
||||||
name: string;
|
name: string;
|
||||||
baseName?: string;
|
baseName?: string;
|
||||||
|
details: string;
|
||||||
|
baseDetails?: string;
|
||||||
isEventItem: boolean;
|
isEventItem: boolean;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
image: EntityImage | null;
|
image: EntityImage | null;
|
||||||
@@ -270,6 +274,24 @@ export interface Item extends EditInfo {
|
|||||||
recipe: RecipeSummary | null;
|
recipe: RecipeSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AncientArtifact extends EditInfo {
|
||||||
|
id: number;
|
||||||
|
displayId: number;
|
||||||
|
name: string;
|
||||||
|
baseName?: string;
|
||||||
|
details: string;
|
||||||
|
baseDetails?: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
|
category: NamedEntity;
|
||||||
|
tags: NamedEntity[];
|
||||||
|
image: EntityImage | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AncientArtifactDetail extends AncientArtifact {
|
||||||
|
editHistory: EditHistoryEntry[];
|
||||||
|
imageHistory: EntityImageUpload[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ItemDetail extends Item {
|
export interface ItemDetail extends Item {
|
||||||
acquisitionMethods: NamedEntity[];
|
acquisitionMethods: NamedEntity[];
|
||||||
recipe: RecipeDetail | null;
|
recipe: RecipeDetail | null;
|
||||||
@@ -296,7 +318,7 @@ export interface DailyChecklistItem {
|
|||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
|
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||||
|
|
||||||
export interface DataToolScopeSummary {
|
export interface DataToolScopeSummary {
|
||||||
scope: DataToolScope;
|
scope: DataToolScope;
|
||||||
@@ -395,6 +417,7 @@ export interface Options {
|
|||||||
favoriteThings: NamedEntity[];
|
favoriteThings: NamedEntity[];
|
||||||
itemCategories: NamedEntity[];
|
itemCategories: NamedEntity[];
|
||||||
itemUsages: NamedEntity[];
|
itemUsages: NamedEntity[];
|
||||||
|
ancientArtifactCategories: NamedEntity[];
|
||||||
acquisitionMethods: NamedEntity[];
|
acquisitionMethods: NamedEntity[];
|
||||||
itemTags: NamedEntity[];
|
itemTags: NamedEntity[];
|
||||||
maps: NamedEntity[];
|
maps: NamedEntity[];
|
||||||
@@ -546,8 +569,6 @@ export type ConfigType =
|
|||||||
| 'skills'
|
| 'skills'
|
||||||
| 'environments'
|
| 'environments'
|
||||||
| 'favorite-things'
|
| 'favorite-things'
|
||||||
| 'item-categories'
|
|
||||||
| 'item-usages'
|
|
||||||
| 'acquisition-methods'
|
| 'acquisition-methods'
|
||||||
| 'maps'
|
| 'maps'
|
||||||
| 'life-tags'
|
| 'life-tags'
|
||||||
@@ -598,7 +619,9 @@ export interface PokemonImageOptionsResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemPayload {
|
export interface ItemPayload {
|
||||||
|
displayId: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
details: string;
|
||||||
translations?: TranslationMap;
|
translations?: TranslationMap;
|
||||||
categoryId: number;
|
categoryId: number;
|
||||||
usageId: number | null;
|
usageId: number | null;
|
||||||
@@ -612,6 +635,16 @@ export interface ItemPayload {
|
|||||||
imagePath: string;
|
imagePath: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AncientArtifactPayload {
|
||||||
|
displayId: number;
|
||||||
|
name: string;
|
||||||
|
details: string;
|
||||||
|
translations?: TranslationMap;
|
||||||
|
categoryId: number;
|
||||||
|
tagIds: number[];
|
||||||
|
imagePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RecipePayload {
|
export interface RecipePayload {
|
||||||
itemId: number;
|
itemId: number;
|
||||||
acquisitionMethodIds: number[];
|
acquisitionMethodIds: number[];
|
||||||
@@ -650,7 +683,7 @@ export interface LifeCommentPayload {
|
|||||||
languageCode?: string | null;
|
languageCode?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
||||||
|
|
||||||
export interface EntityDiscussionComment {
|
export interface EntityDiscussionComment {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -1104,13 +1137,23 @@ export const api = {
|
|||||||
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
||||||
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
||||||
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
reorderHabitats: (ids: number[]) => sendJson<Habitat[]>('/api/admin/habitats/order', 'PUT', { ids }),
|
||||||
items: (params: Record<string, string | number | undefined>) =>
|
items: (params: Record<string, string | number | boolean | undefined>) =>
|
||||||
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
getJson<Item[]>(`/api/items${buildQuery(params)}`),
|
||||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||||
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||||
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||||
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
||||||
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
reorderItems: (ids: number[]) => sendJson<Item[]>('/api/admin/items/order', 'PUT', { ids }),
|
||||||
|
ancientArtifacts: (params: Record<string, string | number | undefined> = {}) =>
|
||||||
|
getJson<AncientArtifact[]>(`/api/ancient-artifacts${buildQuery(params)}`),
|
||||||
|
ancientArtifactDetail: (id: string | number) => getJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`),
|
||||||
|
createAncientArtifact: (payload: AncientArtifactPayload) =>
|
||||||
|
sendJson<AncientArtifactDetail>('/api/ancient-artifacts', 'POST', payload),
|
||||||
|
updateAncientArtifact: (id: string | number, payload: AncientArtifactPayload) =>
|
||||||
|
sendJson<AncientArtifactDetail>(`/api/ancient-artifacts/${id}`, 'PUT', payload),
|
||||||
|
deleteAncientArtifact: (id: string | number) => deleteJson(`/api/ancient-artifacts/${id}`),
|
||||||
|
reorderAncientArtifacts: (ids: number[]) =>
|
||||||
|
sendJson<AncientArtifact[]>('/api/admin/ancient-artifacts/order', 'PUT', { ids }),
|
||||||
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
recipes: (params: Record<string, string | number | undefined> = {}) =>
|
||||||
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
||||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||||
|
|||||||
@@ -4114,6 +4114,13 @@ button:disabled,
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preserve-lines {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 72ch;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-profile-facts {
|
.entity-profile-facts {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import TranslationFields from '../components/TranslationFields.vue';
|
|||||||
import {
|
import {
|
||||||
iconAdd,
|
iconAdd,
|
||||||
iconAdmin,
|
iconAdmin,
|
||||||
|
iconArtifact,
|
||||||
iconCancel,
|
iconCancel,
|
||||||
iconChecklist,
|
iconChecklist,
|
||||||
iconDelete,
|
iconDelete,
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
|
type AncientArtifact,
|
||||||
type AiModerationApiFormat,
|
type AiModerationApiFormat,
|
||||||
type AiModerationAuthMode,
|
type AiModerationAuthMode,
|
||||||
type AiModerationSettings,
|
type AiModerationSettings,
|
||||||
@@ -76,6 +78,7 @@ type AdminTab =
|
|||||||
| 'checklist'
|
| 'checklist'
|
||||||
| 'pokemon'
|
| 'pokemon'
|
||||||
| 'items'
|
| 'items'
|
||||||
|
| 'ancientArtifacts'
|
||||||
| 'recipes'
|
| 'recipes'
|
||||||
| 'habitats';
|
| 'habitats';
|
||||||
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
||||||
@@ -102,7 +105,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
|
|||||||
'upload',
|
'upload',
|
||||||
'fetch'
|
'fetch'
|
||||||
];
|
];
|
||||||
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
|
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'];
|
||||||
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
|
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
|
||||||
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
|
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
|
||||||
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
|
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
|
||||||
@@ -126,6 +129,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
|||||||
checklist: iconChecklist,
|
checklist: iconChecklist,
|
||||||
pokemon: iconPokemon,
|
pokemon: iconPokemon,
|
||||||
items: iconItem,
|
items: iconItem,
|
||||||
|
ancientArtifacts: iconArtifact,
|
||||||
recipes: iconRecipe,
|
recipes: iconRecipe,
|
||||||
habitats: iconHabitat
|
habitats: iconHabitat
|
||||||
};
|
};
|
||||||
@@ -146,6 +150,11 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
|
|||||||
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
|
{ key: 'checklist', label: t('pages.admin.checklist'), permission: ['checklist.create', 'checklist.update', 'checklist.delete', 'checklist.order'] },
|
||||||
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
|
{ key: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.delete'] },
|
||||||
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
|
{ key: 'items', label: t('pages.admin.itemList'), permission: ['items.order', 'items.delete'] },
|
||||||
|
{
|
||||||
|
key: 'ancientArtifacts',
|
||||||
|
label: t('pages.admin.ancientArtifactList'),
|
||||||
|
permission: ['ancient-artifacts.order', 'ancient-artifacts.delete']
|
||||||
|
},
|
||||||
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
|
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
|
||||||
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
|
{ key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
|
||||||
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
|
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
|
||||||
@@ -185,8 +194,6 @@ const configTypes = computed<
|
|||||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||||
{ key: 'environments', label: t('config.environments') },
|
{ key: 'environments', label: t('config.environments') },
|
||||||
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
{ key: 'favorite-things', label: t('config.favoriteThings') },
|
||||||
{ key: 'item-categories', label: t('config.itemCategories') },
|
|
||||||
{ key: 'item-usages', label: t('config.itemUsages') },
|
|
||||||
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
|
||||||
{ key: 'maps', label: t('config.maps') },
|
{ key: 'maps', label: t('config.maps') },
|
||||||
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
|
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
|
||||||
@@ -203,6 +210,7 @@ const languageRows = ref<Language[]>([]);
|
|||||||
const checklistRows = ref<DailyChecklistItem[]>([]);
|
const checklistRows = ref<DailyChecklistItem[]>([]);
|
||||||
const pokemonRows = ref<Pokemon[]>([]);
|
const pokemonRows = ref<Pokemon[]>([]);
|
||||||
const itemRows = ref<Item[]>([]);
|
const itemRows = ref<Item[]>([]);
|
||||||
|
const ancientArtifactRows = ref<AncientArtifact[]>([]);
|
||||||
const recipeRows = ref<Recipe[]>([]);
|
const recipeRows = ref<Recipe[]>([]);
|
||||||
const habitatRows = ref<Habitat[]>([]);
|
const habitatRows = ref<Habitat[]>([]);
|
||||||
const wordingRows = ref<SystemWording[]>([]);
|
const wordingRows = ref<SystemWording[]>([]);
|
||||||
@@ -401,7 +409,9 @@ const configLabel = (item: EditableConfig) => item.name;
|
|||||||
const pokemonKey = (item: Pokemon) => item.id;
|
const pokemonKey = (item: Pokemon) => item.id;
|
||||||
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
|
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
|
||||||
const itemKey = (item: Item) => item.id;
|
const itemKey = (item: Item) => item.id;
|
||||||
const itemLabel = (item: Item) => item.name;
|
const itemLabel = (item: Item) => `#${item.displayId} ${item.name}`;
|
||||||
|
const ancientArtifactKey = (item: AncientArtifact) => item.id;
|
||||||
|
const ancientArtifactLabel = (item: AncientArtifact) => `#${item.displayId} ${item.name}`;
|
||||||
const recipeKey = (item: Recipe) => item.id;
|
const recipeKey = (item: Recipe) => item.id;
|
||||||
const recipeLabel = (item: Recipe) => item.name;
|
const recipeLabel = (item: Recipe) => item.name;
|
||||||
const habitatKey = (item: Habitat) => item.id;
|
const habitatKey = (item: Habitat) => item.id;
|
||||||
@@ -768,6 +778,10 @@ function previewItemOrder(rows: Item[]) {
|
|||||||
itemRows.value = rows;
|
itemRows.value = rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function previewAncientArtifactOrder(rows: AncientArtifact[]) {
|
||||||
|
ancientArtifactRows.value = rows;
|
||||||
|
}
|
||||||
|
|
||||||
function previewRecipeOrder(rows: Recipe[]) {
|
function previewRecipeOrder(rows: Recipe[]) {
|
||||||
recipeRows.value = rows;
|
recipeRows.value = rows;
|
||||||
}
|
}
|
||||||
@@ -837,6 +851,18 @@ async function persistItemOrder(nextRows: Item[], fallbackRows: Item[]) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function persistAncientArtifactOrder(nextRows: AncientArtifact[], fallbackRows: AncientArtifact[]) {
|
||||||
|
ancientArtifactRows.value = nextRows;
|
||||||
|
await run(async () => {
|
||||||
|
try {
|
||||||
|
ancientArtifactRows.value = await api.reorderAncientArtifacts(nextRows.map((item) => item.id));
|
||||||
|
} catch (error) {
|
||||||
|
ancientArtifactRows.value = fallbackRows;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
|
async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) {
|
||||||
recipeRows.value = nextRows;
|
recipeRows.value = nextRows;
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
@@ -944,6 +970,10 @@ async function loadItems() {
|
|||||||
itemRows.value = await api.items({});
|
itemRows.value = await api.items({});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadAncientArtifacts() {
|
||||||
|
ancientArtifactRows.value = await api.ancientArtifacts();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRecipes() {
|
async function loadRecipes() {
|
||||||
recipeRows.value = await api.recipes();
|
recipeRows.value = await api.recipes();
|
||||||
}
|
}
|
||||||
@@ -1121,6 +1151,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
|||||||
if (activeTab.value === 'checklist') await loadChecklist();
|
if (activeTab.value === 'checklist') await loadChecklist();
|
||||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||||
if (activeTab.value === 'items') await loadItems();
|
if (activeTab.value === 'items') await loadItems();
|
||||||
|
if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts();
|
||||||
if (activeTab.value === 'recipes') await loadRecipes();
|
if (activeTab.value === 'recipes') await loadRecipes();
|
||||||
if (activeTab.value === 'habitats') await loadHabitats();
|
if (activeTab.value === 'habitats') await loadHabitats();
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1208,6 +1239,13 @@ async function removeItem(id: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeAncientArtifact(id: number) {
|
||||||
|
await run(async () => {
|
||||||
|
await api.deleteAncientArtifact(id);
|
||||||
|
await loadAncientArtifacts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function removeRecipe(id: number) {
|
async function removeRecipe(id: number) {
|
||||||
await run(async () => {
|
await run(async () => {
|
||||||
await api.deleteRecipe(id);
|
await api.deleteRecipe(id);
|
||||||
@@ -1982,7 +2020,7 @@ onMounted(() => {
|
|||||||
@reorder="persistItemOrder"
|
@reorder="persistItemOrder"
|
||||||
>
|
>
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
|
<RouterLink :to="`/items/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
|
||||||
<span class="row-actions">
|
<span class="row-actions">
|
||||||
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
|
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
|
||||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||||
@@ -1994,6 +2032,34 @@ onMounted(() => {
|
|||||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="canEdit && activeTab === 'ancientArtifacts'" class="detail-section">
|
||||||
|
<h2>{{ t('pages.admin.ancientArtifactList') }}</h2>
|
||||||
|
<ReorderableList
|
||||||
|
v-if="ancientArtifactRows.length"
|
||||||
|
:items="ancientArtifactRows"
|
||||||
|
:item-key="ancientArtifactKey"
|
||||||
|
:item-label="ancientArtifactLabel"
|
||||||
|
list-key-prefix="ancient-artifacts"
|
||||||
|
:disabled="busy || !can('ancient-artifacts.order')"
|
||||||
|
:handle-label="dragSortLabel"
|
||||||
|
:handle-title="t('pages.admin.dragSortTitle')"
|
||||||
|
@preview="previewAncientArtifactOrder"
|
||||||
|
@cancel="previewAncientArtifactOrder"
|
||||||
|
@reorder="persistAncientArtifactOrder"
|
||||||
|
>
|
||||||
|
<template #default="{ item }">
|
||||||
|
<RouterLink :to="`/ancient-artifacts/${item.id}`">#{{ item.displayId }} {{ 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" />
|
||||||
|
{{ t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ReorderableList>
|
||||||
|
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
|
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
|
||||||
<h2>{{ t('pages.admin.recipeList') }}</h2>
|
<h2>{{ t('pages.admin.recipeList') }}</h2>
|
||||||
<ReorderableList
|
<ReorderableList
|
||||||
|
|||||||
170
frontend/src/views/AncientArtifactDetail.vue
Normal file
170
frontend/src/views/AncientArtifactDetail.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import DetailSection from '../components/DetailSection.vue';
|
||||||
|
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
|
||||||
|
import EntityChips from '../components/EntityChips.vue';
|
||||||
|
import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
|
import { iconArtifact, iconBack, iconEdit } from '../icons';
|
||||||
|
import { applySeo } from '../seo';
|
||||||
|
import { api, getAuthToken, type AncientArtifactDetail, type AuthUser } from '../services/api';
|
||||||
|
import AncientArtifactEdit from './AncientArtifactEdit.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const artifact = ref<AncientArtifactDetail | null>(null);
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const detailTab = ref('details');
|
||||||
|
const showEditor = computed(() => route.name === 'ancient-artifact-edit');
|
||||||
|
const canUpdateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.update') === true);
|
||||||
|
const detailTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: 'details', label: t('common.details') },
|
||||||
|
{ value: 'discussion', label: t('discussion.title') },
|
||||||
|
{ value: 'history', label: t('history.editHistory') }
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function loadArtifactDetail() {
|
||||||
|
const nextArtifact = await api.ancientArtifactDetail(String(route.params.id));
|
||||||
|
artifact.value = nextArtifact;
|
||||||
|
|
||||||
|
if (route.meta.editorModal !== true) {
|
||||||
|
applySeo({
|
||||||
|
title: `${nextArtifact.name} - ${t('pages.ancientArtifacts.title')}`,
|
||||||
|
description: t('seo.ancientArtifactDetailDescription', { name: nextArtifact.name }),
|
||||||
|
canonicalPath: `/ancient-artifacts/${nextArtifact.id}`,
|
||||||
|
image: nextArtifact.image?.url
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (getAuthToken()) {
|
||||||
|
try {
|
||||||
|
currentUser.value = (await api.me()).user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadArtifactDetail();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(name, oldName) => {
|
||||||
|
if (oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail') {
|
||||||
|
void loadArtifactDetail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.params.id,
|
||||||
|
() => {
|
||||||
|
artifact.value = null;
|
||||||
|
detailTab.value = 'details';
|
||||||
|
void loadArtifactDetail();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section v-if="!artifact" class="page-stack" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingDetail')">
|
||||||
|
<div class="page-header page-header--skeleton" aria-hidden="true">
|
||||||
|
<div class="page-header__copy">
|
||||||
|
<Skeleton width="132px" />
|
||||||
|
<Skeleton width="260px" height="46px" />
|
||||||
|
<Skeleton width="220px" />
|
||||||
|
</div>
|
||||||
|
<div class="page-header__actions">
|
||||||
|
<Skeleton variant="box" width="88px" height="36px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="detail-section skeleton-detail-section" aria-hidden="true">
|
||||||
|
<div class="detail-section__header">
|
||||||
|
<Skeleton width="112px" height="24px" />
|
||||||
|
</div>
|
||||||
|
<div class="detail-section__body">
|
||||||
|
<Skeleton width="45%" />
|
||||||
|
<Skeleton variant="box" height="120px" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
<section v-else class="page-stack">
|
||||||
|
<PageHeader :title="`#${artifact.displayId} ${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`">
|
||||||
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.edit') }}
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/ancient-artifacts">
|
||||||
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.backToList') }}
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="detail-tabs">
|
||||||
|
<Tabs id="artifact-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection :title="t('media.image')">
|
||||||
|
<div class="entity-detail-image">
|
||||||
|
<div class="entity-detail-image__frame" :class="{ 'entity-detail-image__frame--placeholder': !artifact.image }">
|
||||||
|
<img v-if="artifact.image" :src="artifact.image.url" :alt="t('media.imageAlt', { name: artifact.name })" />
|
||||||
|
<span v-else class="entity-card__mark entity-detail-image__mark" role="img" :aria-label="t('media.imageEmpty')">
|
||||||
|
<Icon :icon="iconArtifact" class="entity-card__icon" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection :title="t('pages.ancientArtifacts.description')">
|
||||||
|
<p v-if="artifact.details" class="preserve-lines">{{ artifact.details }}</p>
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection :title="t('pages.ancientArtifacts.category')">
|
||||||
|
<span class="chip">
|
||||||
|
<Icon :icon="iconArtifact" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ artifact.category.name }}
|
||||||
|
</span>
|
||||||
|
</DetailSection>
|
||||||
|
|
||||||
|
<DetailSection :title="t('pages.ancientArtifacts.tags')">
|
||||||
|
<EntityChips v-if="artifact.tags.length" :items="artifact.tags" />
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</DetailSection>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="detailTab === 'discussion'" class="detail-tab-panel">
|
||||||
|
<EntityDiscussionPanel entity-type="ancient-artifacts" :entity-id="artifact.id" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="detail-tab-panel">
|
||||||
|
<EditHistoryPanel :entity="artifact" :history="artifact.editHistory" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AncientArtifactEdit v-if="showEditor" />
|
||||||
|
</template>
|
||||||
269
frontend/src/views/AncientArtifactEdit.vue
Normal file
269
frontend/src/views/AncientArtifactEdit.vue
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||||
|
import Modal from '../components/Modal.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
|
import { iconCancel, iconSave } from '../icons';
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
getAuthToken,
|
||||||
|
type AncientArtifactPayload,
|
||||||
|
type AuthUser,
|
||||||
|
type ConfigType,
|
||||||
|
type EntityImage,
|
||||||
|
type EntityImageUpload,
|
||||||
|
type Language,
|
||||||
|
type Options,
|
||||||
|
type TranslationMap
|
||||||
|
} from '../services/api';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { locale, t } = useI18n();
|
||||||
|
const options = ref<Options | null>(null);
|
||||||
|
const languages = ref<Language[]>([]);
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const currentImage = ref<EntityImage | null>(null);
|
||||||
|
const imageHistory = ref<EntityImageUpload[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const busy = ref(false);
|
||||||
|
const message = ref('');
|
||||||
|
const creatingSelect = ref('');
|
||||||
|
const artifactForm = ref({
|
||||||
|
displayId: 1,
|
||||||
|
name: '',
|
||||||
|
details: '',
|
||||||
|
translations: {} as TranslationMap,
|
||||||
|
categoryId: '',
|
||||||
|
tagIds: [] as string[],
|
||||||
|
imagePath: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
|
const isEditing = computed(() => routeId.value !== '');
|
||||||
|
const pageTitle = computed(() =>
|
||||||
|
isEditing.value
|
||||||
|
? t('pages.ancientArtifacts.editTitle', { name: artifactForm.value.name || t('pages.ancientArtifacts.fallbackName') })
|
||||||
|
: t('pages.ancientArtifacts.newTitle')
|
||||||
|
);
|
||||||
|
const cancelTo = computed(() => (isEditing.value ? `/ancient-artifacts/${routeId.value}` : '/ancient-artifacts'));
|
||||||
|
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||||
|
const canUploadImage = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.upload') === true);
|
||||||
|
const imageEntityName = computed(() => artifactNameForSave().trim());
|
||||||
|
|
||||||
|
function toIds(values: string[]): number[] {
|
||||||
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
void router.push(cancelTo.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function artifactNameForSave() {
|
||||||
|
const baseName = artifactForm.value.name.trim();
|
||||||
|
if (baseName !== '') {
|
||||||
|
return artifactForm.value.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return artifactForm.value.translations[String(locale.value || '')]?.name ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEditor() {
|
||||||
|
loading.value = true;
|
||||||
|
message.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
|
||||||
|
options.value = loadedOptions;
|
||||||
|
languages.value = loadedLanguages;
|
||||||
|
|
||||||
|
if (getAuthToken()) {
|
||||||
|
try {
|
||||||
|
currentUser.value = (await api.me()).user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ?? {},
|
||||||
|
categoryId: String(artifact.category.id),
|
||||||
|
tagIds: artifact.tags.map((tag) => String(tag.id)),
|
||||||
|
imagePath: artifact.image?.path ?? ''
|
||||||
|
};
|
||||||
|
currentImage.value = artifact.image;
|
||||||
|
imageHistory.value = artifact.imageHistory;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error, t('errors.loadFailed'));
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
||||||
|
const cleanName = name.trim();
|
||||||
|
if (!cleanName || !canCreateConfig.value) return;
|
||||||
|
|
||||||
|
creatingSelect.value = selectKey;
|
||||||
|
message.value = '';
|
||||||
|
try {
|
||||||
|
const created = await api.createConfig(type, { name: cleanName });
|
||||||
|
options.value = await api.options();
|
||||||
|
const value = String(created.id);
|
||||||
|
if (!values.includes(value)) {
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error, t('errors.addFailed'));
|
||||||
|
} finally {
|
||||||
|
creatingSelect.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveArtifact() {
|
||||||
|
busy.value = true;
|
||||||
|
message.value = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: AncientArtifactPayload = {
|
||||||
|
displayId: artifactForm.value.displayId,
|
||||||
|
name: artifactNameForSave(),
|
||||||
|
details: artifactForm.value.details,
|
||||||
|
translations: artifactForm.value.translations,
|
||||||
|
categoryId: Number(artifactForm.value.categoryId),
|
||||||
|
tagIds: toIds(artifactForm.value.tagIds),
|
||||||
|
imagePath: artifactForm.value.imagePath
|
||||||
|
};
|
||||||
|
const saved = isEditing.value
|
||||||
|
? await api.updateAncientArtifact(routeId.value, payload)
|
||||||
|
: await api.createAncientArtifact(payload);
|
||||||
|
await router.push(`/ancient-artifacts/${saved.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
message.value = errorText(error, t('errors.saveFailed'));
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageSelected(image: EntityImage) {
|
||||||
|
currentImage.value = image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageUploaded(image: EntityImageUpload) {
|
||||||
|
currentImage.value = image;
|
||||||
|
imageHistory.value = [image, ...imageHistory.value.filter((item) => item.path !== image.path)];
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void loadEditor();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :title="pageTitle" :subtitle="t('pages.ancientArtifacts.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
|
||||||
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
||||||
|
|
||||||
|
<form v-if="!loading && options" id="artifact-edit-form" class="modal-edit-form" @submit.prevent="saveArtifact">
|
||||||
|
<TranslationFields
|
||||||
|
id-prefix="artifact-name"
|
||||||
|
v-model:base-value="artifactForm.name"
|
||||||
|
v-model:translations="artifactForm.translations"
|
||||||
|
field="name"
|
||||||
|
:label="t('common.name')"
|
||||||
|
:languages="languages"
|
||||||
|
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"
|
||||||
|
v-model:translations="artifactForm.translations"
|
||||||
|
field="details"
|
||||||
|
:label="t('pages.ancientArtifacts.description')"
|
||||||
|
:languages="languages"
|
||||||
|
multiline
|
||||||
|
:rows="4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="artifact-category">{{ t('pages.ancientArtifacts.category') }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
id="artifact-category"
|
||||||
|
v-model="artifactForm.categoryId"
|
||||||
|
:options="options.ancientArtifactCategories"
|
||||||
|
:multiple="false"
|
||||||
|
:placeholder="t('common.select')"
|
||||||
|
:search-placeholder="t('pages.ancientArtifacts.searchCategory')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageUploadField
|
||||||
|
v-model="artifactForm.imagePath"
|
||||||
|
entity-type="ancient-artifacts"
|
||||||
|
:entity-id="isEditing ? routeId : null"
|
||||||
|
:entity-name="imageEntityName"
|
||||||
|
:label="t('media.image')"
|
||||||
|
:current-image="currentImage"
|
||||||
|
:history="imageHistory"
|
||||||
|
:disabled="busy"
|
||||||
|
:allow-upload="canUploadImage"
|
||||||
|
@selected="handleImageSelected"
|
||||||
|
@uploaded="handleImageUploaded"
|
||||||
|
@error="message = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
id="artifact-tags"
|
||||||
|
v-model="artifactForm.tagIds"
|
||||||
|
:options="options.itemTags"
|
||||||
|
:allow-create="canCreateConfig"
|
||||||
|
:creating="creatingSelect === 'artifact-tags'"
|
||||||
|
:placeholder="t('pages.ancientArtifacts.searchTags')"
|
||||||
|
@create="createMultiOption('artifact-tags', 'favorite-things', $event, artifactForm.tagIds)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.ancientArtifacts.loadingEdit')">
|
||||||
|
<Skeleton width="160px" />
|
||||||
|
<Skeleton variant="box" height="44px" />
|
||||||
|
<Skeleton width="140px" />
|
||||||
|
<Skeleton variant="box" height="120px" />
|
||||||
|
<Skeleton width="120px" />
|
||||||
|
<Skeleton variant="box" height="44px" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<button type="button" class="ui-button ui-button--ghost" @click="closeEditor">
|
||||||
|
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" form="artifact-edit-form" class="ui-button ui-button--primary" :disabled="busy || loading">
|
||||||
|
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ busy ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
139
frontend/src/views/AncientArtifactList.vue
Normal file
139
frontend/src/views/AncientArtifactList.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import EntityCard from '../components/EntityCard.vue';
|
||||||
|
import FilterPanel from '../components/FilterPanel.vue';
|
||||||
|
import PageHeader from '../components/PageHeader.vue';
|
||||||
|
import Skeleton from '../components/Skeleton.vue';
|
||||||
|
import Tabs, { type TabOption } from '../components/Tabs.vue';
|
||||||
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
|
import { iconAdd, iconArtifact } from '../icons';
|
||||||
|
import { api, getAuthToken, type AncientArtifact, type AuthUser, type Options } from '../services/api';
|
||||||
|
import AncientArtifactEdit from './AncientArtifactEdit.vue';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const options = ref<Options | null>(null);
|
||||||
|
const artifacts = ref<AncientArtifact[]>([]);
|
||||||
|
const currentUser = ref<AuthUser | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const search = ref('');
|
||||||
|
const categoryId = ref('');
|
||||||
|
const tagIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
const categorySkeletonWidths = ['64px', '132px', '132px', '86px'];
|
||||||
|
const filterSkeletonWidths = ['52px', '36px'];
|
||||||
|
const skeletonCardCount = 6;
|
||||||
|
|
||||||
|
const categoryTabs = computed<TabOption[]>(() => [
|
||||||
|
{ value: '', label: t('common.all') },
|
||||||
|
...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
|
||||||
|
]);
|
||||||
|
const artifactQuery = computed(() => ({
|
||||||
|
search: search.value,
|
||||||
|
categoryId: categoryId.value,
|
||||||
|
tagIds: tagIds.value.join(',')
|
||||||
|
}));
|
||||||
|
const showEditor = computed(() => route.name === 'ancient-artifact-new');
|
||||||
|
const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.create') === true);
|
||||||
|
|
||||||
|
function artifactCardImage(artifact: AncientArtifact) {
|
||||||
|
return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArtifacts() {
|
||||||
|
loading.value = true;
|
||||||
|
artifacts.value = await api.ancientArtifacts(artifactQuery.value);
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (getAuthToken()) {
|
||||||
|
try {
|
||||||
|
currentUser.value = (await api.me()).user;
|
||||||
|
} catch {
|
||||||
|
currentUser.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
options.value = await api.options();
|
||||||
|
await loadArtifacts();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(artifactQuery, loadArtifacts);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-stack">
|
||||||
|
<PageHeader :title="t('pages.ancientArtifacts.title')" :subtitle="t('pages.ancientArtifacts.subtitle')">
|
||||||
|
<template #kicker>{{ t('pages.ancientArtifacts.kicker') }}</template>
|
||||||
|
<template #actions>
|
||||||
|
<RouterLink v-if="canCreateArtifact" class="ui-button ui-button--primary ui-button--small" to="/ancient-artifacts/new">
|
||||||
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
|
{{ t('common.add') }}
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<Tabs v-if="options" id="artifact-category" v-model="categoryId" :tabs="categoryTabs" :label="t('pages.ancientArtifacts.category')" />
|
||||||
|
<div v-else class="tabs tabs--component" aria-hidden="true">
|
||||||
|
<div class="tab-list tab-list--skeleton">
|
||||||
|
<Skeleton
|
||||||
|
v-for="width in categorySkeletonWidths"
|
||||||
|
:key="width"
|
||||||
|
variant="box"
|
||||||
|
:width="width"
|
||||||
|
height="42px"
|
||||||
|
class="skeleton-tab"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterPanel v-if="options">
|
||||||
|
<div class="field">
|
||||||
|
<label for="artifact-search">{{ t('common.search') }}</label>
|
||||||
|
<input id="artifact-search" v-model="search" type="search" :placeholder="t('common.name')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="artifact-tags">{{ t('pages.ancientArtifacts.tags') }}</label>
|
||||||
|
<TagsSelect
|
||||||
|
id="artifact-tags"
|
||||||
|
v-model="tagIds"
|
||||||
|
:options="options.itemTags"
|
||||||
|
:placeholder="t('pages.ancientArtifacts.searchTags')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FilterPanel>
|
||||||
|
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
|
||||||
|
<div v-for="(width, index) in filterSkeletonWidths" :key="index" class="field">
|
||||||
|
<Skeleton :width="width" />
|
||||||
|
<Skeleton variant="box" height="44px" />
|
||||||
|
</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">
|
||||||
|
<Skeleton variant="box" width="92px" height="92px" class="skeleton-entity-mark" />
|
||||||
|
<div class="entity-card__content">
|
||||||
|
<Skeleton width="128px" height="24px" />
|
||||||
|
<Skeleton width="92px" />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div v-else class="entity-grid catalog-card-grid">
|
||||||
|
<EntityCard
|
||||||
|
v-for="artifact in artifacts"
|
||||||
|
:key="artifact.id"
|
||||||
|
:title="`#${artifact.displayId} ${artifact.name}`"
|
||||||
|
:subtitle="artifact.category.name"
|
||||||
|
:to="`/ancient-artifacts/${artifact.id}`"
|
||||||
|
:icon="iconArtifact"
|
||||||
|
:image="artifactCardImage(artifact)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AncientArtifactEdit v-if="showEditor" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -7,6 +7,7 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import StatusBadge from '../components/StatusBadge.vue';
|
import StatusBadge from '../components/StatusBadge.vue';
|
||||||
import {
|
import {
|
||||||
iconAction,
|
iconAction,
|
||||||
|
iconArtifact,
|
||||||
iconAutomation,
|
iconAutomation,
|
||||||
iconChevronRight,
|
iconChevronRight,
|
||||||
iconChecklist,
|
iconChecklist,
|
||||||
@@ -36,6 +37,8 @@ const primarySections = computed(() => [
|
|||||||
{ key: 'habitats', to: '/habitats', icon: iconHabitat },
|
{ key: 'habitats', to: '/habitats', icon: iconHabitat },
|
||||||
{ key: 'eventHabitats', to: '/event-habitats', icon: iconEvent },
|
{ key: 'eventHabitats', to: '/event-habitats', icon: iconEvent },
|
||||||
{ key: 'items', to: '/items', icon: iconItem },
|
{ key: 'items', to: '/items', icon: iconItem },
|
||||||
|
{ key: 'eventItems', to: '/event-items', icon: iconEvent },
|
||||||
|
{ key: 'ancientArtifacts', to: '/ancient-artifacts', icon: iconArtifact },
|
||||||
{ key: 'recipes', to: '/recipes', icon: iconRecipe }
|
{ key: 'recipes', to: '/recipes', icon: iconRecipe }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ const itemSubtitle = computed(() => {
|
|||||||
|
|
||||||
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
|
return item.value.usage ? `${item.value.category.name} · ${item.value.usage.name}` : item.value.category.name;
|
||||||
});
|
});
|
||||||
|
const detailKicker = computed(() => (item.value?.isEventItem ? t('pages.eventItems.detailKicker') : t('pages.items.detailKicker')));
|
||||||
|
const listTarget = computed(() => (item.value?.isEventItem ? '/event-items' : '/items'));
|
||||||
|
|
||||||
const customization = computed(() => {
|
const customization = computed(() => {
|
||||||
if (!item.value) {
|
if (!item.value) {
|
||||||
@@ -55,7 +57,7 @@ async function loadItemDetail() {
|
|||||||
|
|
||||||
if (route.meta.editorModal !== true) {
|
if (route.meta.editorModal !== true) {
|
||||||
applySeo({
|
applySeo({
|
||||||
title: `${nextItem.name} - ${t('pages.items.title')}`,
|
title: `${nextItem.name} - ${t(nextItem.isEventItem ? 'pages.eventItems.title' : 'pages.items.title')}`,
|
||||||
description: t('seo.itemDetailDescription', { name: nextItem.name }),
|
description: t('seo.itemDetailDescription', { name: nextItem.name }),
|
||||||
canonicalPath: `/items/${nextItem.id}`,
|
canonicalPath: `/items/${nextItem.id}`,
|
||||||
image: nextItem.image?.url
|
image: nextItem.image?.url
|
||||||
@@ -147,14 +149,14 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
<PageHeader :title="`#${item.displayId} ${item.name}`" :subtitle="itemSubtitle">
|
||||||
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
|
<template #kicker>{{ detailKicker }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
||||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('common.edit') }}
|
{{ t('common.edit') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">
|
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="listTarget">
|
||||||
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconBack" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('common.backToList') }}
|
{{ t('common.backToList') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -180,6 +182,10 @@ watch(
|
|||||||
<div class="entity-profile-main">
|
<div class="entity-profile-main">
|
||||||
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||||
<dl class="entity-profile-facts">
|
<dl class="entity-profile-facts">
|
||||||
|
<div>
|
||||||
|
<dt>{{ t('pages.items.displayId') }}</dt>
|
||||||
|
<dd>#{{ item.displayId }}</dd>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt>{{ t('pages.items.category') }}</dt>
|
<dt>{{ t('pages.items.category') }}</dt>
|
||||||
<dd>{{ item.category.name }}</dd>
|
<dd>{{ item.category.name }}</dd>
|
||||||
@@ -195,6 +201,11 @@ watch(
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div class="entity-profile-groups">
|
<div class="entity-profile-groups">
|
||||||
|
<div class="entity-profile-group">
|
||||||
|
<h3 class="section-subtitle">{{ t('pages.items.description') }}</h3>
|
||||||
|
<p v-if="item.details" class="preserve-lines">{{ item.details }}</p>
|
||||||
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
|
</div>
|
||||||
<div class="entity-profile-group">
|
<div class="entity-profile-group">
|
||||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||||
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
|
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ const busy = ref(false);
|
|||||||
const message = ref('');
|
const message = ref('');
|
||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
const itemForm = ref({
|
const itemForm = ref({
|
||||||
|
displayId: 1,
|
||||||
name: '',
|
name: '',
|
||||||
|
details: '',
|
||||||
translations: {} as TranslationMap,
|
translations: {} as TranslationMap,
|
||||||
categoryId: '',
|
categoryId: '',
|
||||||
usageId: '',
|
usageId: '',
|
||||||
@@ -52,12 +54,15 @@ const itemForm = ref({
|
|||||||
|
|
||||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||||
const isEditing = computed(() => routeId.value !== '');
|
const isEditing = computed(() => routeId.value !== '');
|
||||||
|
const isEventCreate = computed(() => route.name === 'event-item-new');
|
||||||
const pageTitle = computed(() =>
|
const pageTitle = computed(() =>
|
||||||
isEditing.value
|
isEditing.value
|
||||||
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
||||||
|
: isEventCreate.value
|
||||||
|
? t('pages.eventItems.newTitle')
|
||||||
: t('pages.items.newTitle')
|
: t('pages.items.newTitle')
|
||||||
);
|
);
|
||||||
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
|
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : isEventCreate.value ? '/event-items' : '/items'));
|
||||||
const hasRecipe = ref(false);
|
const hasRecipe = ref(false);
|
||||||
const imageEntityName = computed(() => itemNameForSave().trim());
|
const imageEntityName = computed(() => itemNameForSave().trim());
|
||||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||||
@@ -112,7 +117,9 @@ async function loadEditor() {
|
|||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
const item = await api.itemDetail(routeId.value);
|
const item = await api.itemDetail(routeId.value);
|
||||||
itemForm.value = {
|
itemForm.value = {
|
||||||
|
displayId: item.displayId,
|
||||||
name: item.baseName ?? item.name,
|
name: item.baseName ?? item.name,
|
||||||
|
details: item.baseDetails ?? item.details,
|
||||||
translations: item.translations ?? {},
|
translations: item.translations ?? {},
|
||||||
categoryId: String(item.category.id),
|
categoryId: String(item.category.id),
|
||||||
usageId: item.usage ? String(item.usage.id) : '',
|
usageId: item.usage ? String(item.usage.id) : '',
|
||||||
@@ -128,6 +135,10 @@ async function loadEditor() {
|
|||||||
currentImage.value = item.image;
|
currentImage.value = item.image;
|
||||||
imageHistory.value = item.imageHistory;
|
imageHistory.value = item.imageHistory;
|
||||||
hasRecipe.value = item.recipe !== null;
|
hasRecipe.value = item.recipe !== null;
|
||||||
|
} else if (isEventCreate.value) {
|
||||||
|
itemForm.value.isEventItem = true;
|
||||||
|
} else {
|
||||||
|
itemForm.value.isEventItem = false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.value = errorText(error, t('errors.loadFailed'));
|
message.value = errorText(error, t('errors.loadFailed'));
|
||||||
@@ -136,23 +147,6 @@ async function loadEditor() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
|
|
||||||
const cleanName = name.trim();
|
|
||||||
if (!cleanName || !canCreateConfig.value) return;
|
|
||||||
|
|
||||||
creatingSelect.value = selectKey;
|
|
||||||
message.value = '';
|
|
||||||
try {
|
|
||||||
const created = await api.createConfig(type, { name: cleanName });
|
|
||||||
await loadOptions();
|
|
||||||
assign(String(created.id));
|
|
||||||
} catch (error) {
|
|
||||||
message.value = errorText(error, t('errors.addFailed'));
|
|
||||||
} finally {
|
|
||||||
creatingSelect.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
||||||
const cleanName = name.trim();
|
const cleanName = name.trim();
|
||||||
if (!cleanName || !canCreateConfig.value) return;
|
if (!cleanName || !canCreateConfig.value) return;
|
||||||
@@ -179,7 +173,9 @@ async function saveItem() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: ItemPayload = {
|
const payload: ItemPayload = {
|
||||||
|
displayId: itemForm.value.displayId,
|
||||||
name: itemNameForSave(),
|
name: itemNameForSave(),
|
||||||
|
details: itemForm.value.details,
|
||||||
translations: itemForm.value.translations,
|
translations: itemForm.value.translations,
|
||||||
categoryId: Number(itemForm.value.categoryId),
|
categoryId: Number(itemForm.value.categoryId),
|
||||||
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
||||||
@@ -230,6 +226,22 @@ onMounted(() => {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="item-display-id">{{ t('pages.items.displayId') }}</label>
|
||||||
|
<input id="item-display-id" v-model.number="itemForm.displayId" type="number" min="1" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TranslationFields
|
||||||
|
id-prefix="item-details"
|
||||||
|
v-model:base-value="itemForm.details"
|
||||||
|
v-model:translations="itemForm.translations"
|
||||||
|
field="details"
|
||||||
|
:label="t('pages.items.description')"
|
||||||
|
:languages="languages"
|
||||||
|
multiline
|
||||||
|
:rows="4"
|
||||||
|
/>
|
||||||
|
|
||||||
<ImageUploadField
|
<ImageUploadField
|
||||||
v-model="itemForm.imagePath"
|
v-model="itemForm.imagePath"
|
||||||
entity-type="items"
|
entity-type="items"
|
||||||
@@ -252,11 +264,8 @@ onMounted(() => {
|
|||||||
v-model="itemForm.categoryId"
|
v-model="itemForm.categoryId"
|
||||||
:options="options.itemCategories"
|
:options="options.itemCategories"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:allow-create="canCreateConfig"
|
|
||||||
:creating="creatingSelect === 'item-category'"
|
|
||||||
:placeholder="t('common.select')"
|
:placeholder="t('common.select')"
|
||||||
:search-placeholder="t('pages.items.searchCategory')"
|
:search-placeholder="t('pages.items.searchCategory')"
|
||||||
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -267,11 +276,8 @@ onMounted(() => {
|
|||||||
v-model="itemForm.usageId"
|
v-model="itemForm.usageId"
|
||||||
:options="options.itemUsages"
|
:options="options.itemUsages"
|
||||||
:multiple="false"
|
:multiple="false"
|
||||||
:allow-create="canCreateConfig"
|
|
||||||
:creating="creatingSelect === 'item-usage'"
|
|
||||||
:placeholder="t('common.none')"
|
:placeholder="t('common.none')"
|
||||||
:search-placeholder="t('pages.items.searchUsage')"
|
:search-placeholder="t('pages.items.searchUsage')"
|
||||||
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,7 +286,7 @@ onMounted(() => {
|
|||||||
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
|
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
|
||||||
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
|
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
|
||||||
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
|
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
|
||||||
<label><input v-model="itemForm.isEventItem" type="checkbox" /> {{ t('pages.items.eventItem') }}</label>
|
<label><input v-model="itemForm.isEventItem" type="checkbox" :disabled="isEventCreate" /> {{ t('pages.items.eventItem') }}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import { iconAdd, iconItem } from '../icons';
|
|||||||
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
|
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
|
||||||
import ItemEdit from './ItemEdit.vue';
|
import ItemEdit from './ItemEdit.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
eventOnly?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
const options = ref<Options | null>(null);
|
const options = ref<Options | null>(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -27,6 +31,10 @@ const tagIds = ref<string[]>([]);
|
|||||||
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
||||||
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
||||||
const skeletonCardCount = 6;
|
const skeletonCardCount = 6;
|
||||||
|
const pageTitle = computed(() => (props.eventOnly ? t('pages.eventItems.title') : t('pages.items.title')));
|
||||||
|
const pageSubtitle = computed(() => (props.eventOnly ? t('pages.eventItems.subtitle') : t('pages.items.subtitle')));
|
||||||
|
const pageKicker = computed(() => (props.eventOnly ? t('pages.eventItems.kicker') : t('pages.items.kicker')));
|
||||||
|
const createTarget = computed(() => (props.eventOnly ? '/event-items/new' : '/items/new'));
|
||||||
|
|
||||||
const categoryTabs = computed<TabOption[]>(() => [
|
const categoryTabs = computed<TabOption[]>(() => [
|
||||||
{ value: '', label: t('common.all') },
|
{ value: '', label: t('common.all') },
|
||||||
@@ -37,9 +45,10 @@ const itemQuery = computed(() => ({
|
|||||||
search: search.value,
|
search: search.value,
|
||||||
categoryId: categoryId.value,
|
categoryId: categoryId.value,
|
||||||
usageId: usageId.value,
|
usageId: usageId.value,
|
||||||
tagIds: tagIds.value.join(',')
|
tagIds: tagIds.value.join(','),
|
||||||
|
isEventItem: props.eventOnly
|
||||||
}));
|
}));
|
||||||
const showEditor = computed(() => route.name === 'item-new');
|
const showEditor = computed(() => route.name === 'item-new' || route.name === 'event-item-new');
|
||||||
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
const canCreateItem = computed(() => currentUser.value?.permissions.includes('items.create') === true);
|
||||||
|
|
||||||
function itemCardImage(item: Item) {
|
function itemCardImage(item: Item) {
|
||||||
@@ -69,10 +78,10 @@ watch(itemQuery, loadItems);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page-stack">
|
<section class="page-stack">
|
||||||
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
|
<PageHeader :title="pageTitle" :subtitle="pageSubtitle">
|
||||||
<template #kicker>Bag</template>
|
<template #kicker>{{ pageKicker }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" to="/items/new">
|
<RouterLink v-if="canCreateItem" class="ui-button ui-button--primary ui-button--small" :to="createTarget">
|
||||||
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
|
||||||
{{ t('common.add') }}
|
{{ t('common.add') }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -136,7 +145,7 @@ watch(itemQuery, loadItems);
|
|||||||
<EntityCard
|
<EntityCard
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="item.name"
|
:title="`#${item.displayId} ${item.name}`"
|
||||||
:subtitle="item.category.name"
|
:subtitle="item.category.name"
|
||||||
:to="`/items/${item.id}`"
|
:to="`/items/${item.id}`"
|
||||||
:icon="iconItem"
|
:icon="iconItem"
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ watch(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-else class="page-stack">
|
<section v-else class="page-stack">
|
||||||
<PageHeader :title="recipe.name" :subtitle="recipeSubtitle">
|
<PageHeader :title="`#${recipe.item.displayId} ${recipe.name}`" :subtitle="recipeSubtitle">
|
||||||
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
|
<template #kicker>{{ t('pages.recipes.detailKicker') }}</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
||||||
@@ -145,7 +145,7 @@ watch(
|
|||||||
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
|
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">{{ recipe.item.name }}</RouterLink>
|
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">#{{ recipe.item.displayId }} {{ recipe.item.name }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ watch(itemQuery, loadItems);
|
|||||||
<EntityCard
|
<EntityCard
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:title="item.name"
|
:title="`#${item.displayId} ${item.name}`"
|
||||||
:subtitle="item.category.name"
|
:subtitle="item.category.name"
|
||||||
:to="recipeTarget(item)"
|
:to="recipeTarget(item)"
|
||||||
:icon="itemIcon(item)"
|
:icon="itemIcon(item)"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
|
|
||||||
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
|
type ProfileTab = 'feeds' | 'contributions' | 'reactions' | 'comments' | 'account';
|
||||||
type PrimaryContributionFilter = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'daily-checklist';
|
type PrimaryContributionFilter = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats' | 'daily-checklist';
|
||||||
type ContributionFilter = 'all' | PrimaryContributionFilter | 'config';
|
type ContributionFilter = 'all' | PrimaryContributionFilter | 'config';
|
||||||
type ReactionFilter = 'all' | LifeReactionType;
|
type ReactionFilter = 'all' | LifeReactionType;
|
||||||
type CommentFilter = 'all' | ProfileCommentSource;
|
type CommentFilter = 'all' | ProfileCommentSource;
|
||||||
@@ -46,6 +46,7 @@ type CommentFilter = 'all' | ProfileCommentSource;
|
|||||||
const primaryContributionFilters: PrimaryContributionFilter[] = [
|
const primaryContributionFilters: PrimaryContributionFilter[] = [
|
||||||
'pokemon',
|
'pokemon',
|
||||||
'items',
|
'items',
|
||||||
|
'ancient-artifacts',
|
||||||
'recipes',
|
'recipes',
|
||||||
'habitats',
|
'habitats',
|
||||||
'daily-checklist'
|
'daily-checklist'
|
||||||
@@ -582,6 +583,7 @@ function contentTypeLabel(contentType: string): string {
|
|||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
pokemon: t('nav.pokemon'),
|
pokemon: t('nav.pokemon'),
|
||||||
items: t('nav.items'),
|
items: t('nav.items'),
|
||||||
|
'ancient-artifacts': t('nav.ancientArtifacts'),
|
||||||
recipes: t('nav.recipes'),
|
recipes: t('nav.recipes'),
|
||||||
habitats: t('nav.habitats'),
|
habitats: t('nav.habitats'),
|
||||||
'daily-checklist': t('nav.checklist'),
|
'daily-checklist': t('nav.checklist'),
|
||||||
@@ -589,8 +591,6 @@ function contentTypeLabel(contentType: string): string {
|
|||||||
skills: t('config.skills'),
|
skills: t('config.skills'),
|
||||||
environments: t('config.environments'),
|
environments: t('config.environments'),
|
||||||
'favorite-things': t('config.favoriteThings'),
|
'favorite-things': t('config.favoriteThings'),
|
||||||
'item-categories': t('config.itemCategories'),
|
|
||||||
'item-usages': t('config.itemUsages'),
|
|
||||||
'acquisition-methods': t('config.acquisitionMethods'),
|
'acquisition-methods': t('config.acquisitionMethods'),
|
||||||
maps: t('config.maps'),
|
maps: t('config.maps'),
|
||||||
'life-tags': t('config.lifeCategories')
|
'life-tags': t('config.lifeCategories')
|
||||||
@@ -603,7 +603,8 @@ function discussionTargetRoute(type: DiscussionEntityType, id: number): string {
|
|||||||
pokemon: `/pokemon/${id}`,
|
pokemon: `/pokemon/${id}`,
|
||||||
items: `/items/${id}`,
|
items: `/items/${id}`,
|
||||||
recipes: `/recipes/${id}`,
|
recipes: `/recipes/${id}`,
|
||||||
habitats: `/habitats/${id}`
|
habitats: `/habitats/${id}`,
|
||||||
|
'ancient-artifacts': `/ancient-artifacts/${id}`
|
||||||
}[type];
|
}[type];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,18 @@ import vue from '@vitejs/plugin-vue';
|
|||||||
|
|
||||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||||
const frontendPort = 20015;
|
const frontendPort = 20015;
|
||||||
const sitemapPaths = ['/pokemon', '/event-pokemon', '/habitats', '/event-habitats', '/items', '/recipes', '/checklist', '/life'];
|
const sitemapPaths = [
|
||||||
|
'/pokemon',
|
||||||
|
'/event-pokemon',
|
||||||
|
'/habitats',
|
||||||
|
'/event-habitats',
|
||||||
|
'/items',
|
||||||
|
'/event-items',
|
||||||
|
'/ancient-artifacts',
|
||||||
|
'/recipes',
|
||||||
|
'/checklist',
|
||||||
|
'/life'
|
||||||
|
];
|
||||||
const robotsDisallowPaths = [
|
const robotsDisallowPaths = [
|
||||||
'/admin',
|
'/admin',
|
||||||
'/login',
|
'/login',
|
||||||
@@ -18,7 +29,10 @@ const robotsDisallowPaths = [
|
|||||||
'/event-habitats/new',
|
'/event-habitats/new',
|
||||||
'/habitats/*/edit',
|
'/habitats/*/edit',
|
||||||
'/items/new',
|
'/items/new',
|
||||||
|
'/event-items/new',
|
||||||
'/items/*/edit',
|
'/items/*/edit',
|
||||||
|
'/ancient-artifacts/new',
|
||||||
|
'/ancient-artifacts/*/edit',
|
||||||
'/recipes/new',
|
'/recipes/new',
|
||||||
'/recipes/*/edit',
|
'/recipes/*/edit',
|
||||||
'/automation',
|
'/automation',
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export const systemWordingMessages = {
|
|||||||
habitats: 'Habitats',
|
habitats: 'Habitats',
|
||||||
eventHabitats: 'Event Habitats',
|
eventHabitats: 'Event Habitats',
|
||||||
items: 'Items',
|
items: 'Items',
|
||||||
|
eventItems: 'Event Items',
|
||||||
|
ancientArtifacts: 'Ancient Artifacts',
|
||||||
recipes: 'Recipes',
|
recipes: 'Recipes',
|
||||||
automation: 'Automation',
|
automation: 'Automation',
|
||||||
dish: 'Dish',
|
dish: 'Dish',
|
||||||
@@ -80,11 +82,13 @@ export const systemWordingMessages = {
|
|||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
siteDescription:
|
siteDescription:
|
||||||
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
|
'Browse Pokopia Wiki for Pokemon, Event Pokemon, habitats, Event Habitats, items, Event Items, Ancient Artifacts, recipes, daily tasks, and Life community posts for Pokemon Pokopia.',
|
||||||
pokemonDetailDescription:
|
pokemonDetailDescription:
|
||||||
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
||||||
itemDetailDescription:
|
itemDetailDescription:
|
||||||
'Browse {name} item details in Pokopia Wiki, including category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.',
|
'Browse {name} item details in Pokopia Wiki, including category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.',
|
||||||
|
ancientArtifactDetailDescription:
|
||||||
|
'Browse {name} Ancient Artifact details in Pokopia Wiki, including category, tags, description, discussions, and edit history.',
|
||||||
habitatDetailDescription:
|
habitatDetailDescription:
|
||||||
'View {name} habitat details in Pokopia Wiki, including recipes, possible Pokemon, maps, time, weather, discussions, and edit history.',
|
'View {name} habitat details in Pokopia Wiki, including recipes, possible Pokemon, maps, time, weather, discussions, and edit history.',
|
||||||
recipeDetailDescription:
|
recipeDetailDescription:
|
||||||
@@ -191,6 +195,14 @@ export const systemWordingMessages = {
|
|||||||
title: 'Items',
|
title: 'Items',
|
||||||
description: 'Browse categories, usage, acquisition methods, customization, and tags.'
|
description: 'Browse categories, usage, acquisition methods, customization, and tags.'
|
||||||
},
|
},
|
||||||
|
eventItems: {
|
||||||
|
title: 'Event Items',
|
||||||
|
description: 'Browse limited event items with their own Display IDs and shared item categories.'
|
||||||
|
},
|
||||||
|
ancientArtifacts: {
|
||||||
|
title: 'Ancient Artifacts',
|
||||||
|
description: 'Browse Lost Relics and Fossils with tags, descriptions, and wiki history.'
|
||||||
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
title: 'Recipes',
|
title: 'Recipes',
|
||||||
description: 'Find result items, materials, and acquisition details.'
|
description: 'Find result items, materials, and acquisition details.'
|
||||||
@@ -608,6 +620,7 @@ export const systemWordingMessages = {
|
|||||||
items: {
|
items: {
|
||||||
title: 'Items',
|
title: 'Items',
|
||||||
subtitle: 'Browse items by category, usage, and tags.',
|
subtitle: 'Browse items by category, usage, and tags.',
|
||||||
|
kicker: 'Items',
|
||||||
detailKicker: 'Item Detail',
|
detailKicker: 'Item Detail',
|
||||||
detailSubtitle: 'Item detail',
|
detailSubtitle: 'Item detail',
|
||||||
editKicker: 'Item Edit',
|
editKicker: 'Item Edit',
|
||||||
@@ -618,6 +631,8 @@ export const systemWordingMessages = {
|
|||||||
loadingList: 'Loading item list',
|
loadingList: 'Loading item list',
|
||||||
loadingDetail: 'Loading item detail',
|
loadingDetail: 'Loading item detail',
|
||||||
loadingEdit: 'Loading item editor',
|
loadingEdit: 'Loading item editor',
|
||||||
|
displayId: 'Display ID',
|
||||||
|
description: 'Description',
|
||||||
category: 'Category',
|
category: 'Category',
|
||||||
usage: 'Usage',
|
usage: 'Usage',
|
||||||
tags: 'Tags',
|
tags: 'Tags',
|
||||||
@@ -638,6 +653,35 @@ export const systemWordingMessages = {
|
|||||||
searchMethods: 'Search acquisition methods',
|
searchMethods: 'Search acquisition methods',
|
||||||
searchTags: 'Search tags'
|
searchTags: 'Search tags'
|
||||||
},
|
},
|
||||||
|
eventItems: {
|
||||||
|
title: 'Event Items',
|
||||||
|
subtitle: 'Browse event items by category, usage, and tags.',
|
||||||
|
kicker: 'Event Items',
|
||||||
|
detailKicker: 'Event Item Detail',
|
||||||
|
editSubtitle: 'Maintain event item category, usage, acquisition methods, customization, and tags.',
|
||||||
|
newTitle: 'New event item'
|
||||||
|
},
|
||||||
|
ancientArtifacts: {
|
||||||
|
title: 'Ancient Artifacts',
|
||||||
|
subtitle: 'Browse Ancient Artifacts by relic, fossil category, and tags.',
|
||||||
|
kicker: 'Ancient Artifacts',
|
||||||
|
detailKicker: 'Ancient Artifact Detail',
|
||||||
|
detailSubtitle: 'Ancient Artifact detail',
|
||||||
|
editKicker: 'Ancient Artifact Edit',
|
||||||
|
editSubtitle: 'Maintain Ancient Artifact Display ID, 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',
|
||||||
|
searchCategory: 'Search categories',
|
||||||
|
searchTags: 'Search tags'
|
||||||
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
title: 'Recipes',
|
title: 'Recipes',
|
||||||
subtitle: 'Browse recipes by category, usage, and tags.',
|
subtitle: 'Browse recipes by category, usage, and tags.',
|
||||||
@@ -862,6 +906,7 @@ export const systemWordingMessages = {
|
|||||||
checklist: 'CheckList',
|
checklist: 'CheckList',
|
||||||
pokemonList: 'Pokemon list',
|
pokemonList: 'Pokemon list',
|
||||||
itemList: 'Item list',
|
itemList: 'Item list',
|
||||||
|
ancientArtifactList: 'Ancient Artifact list',
|
||||||
recipeList: 'Recipe list',
|
recipeList: 'Recipe list',
|
||||||
habitatList: 'Habitat list',
|
habitatList: 'Habitat list',
|
||||||
dataTools: 'Data tools',
|
dataTools: 'Data tools',
|
||||||
@@ -886,6 +931,7 @@ export const systemWordingMessages = {
|
|||||||
dataToolScopePokemon: 'Pokemon',
|
dataToolScopePokemon: 'Pokemon',
|
||||||
dataToolScopeHabitats: 'Habitats',
|
dataToolScopeHabitats: 'Habitats',
|
||||||
dataToolScopeItems: 'Items',
|
dataToolScopeItems: 'Items',
|
||||||
|
dataToolScopeArtifacts: 'Ancient Artifacts',
|
||||||
dataToolScopeRecipes: 'Recipes',
|
dataToolScopeRecipes: 'Recipes',
|
||||||
dataToolScopeChecklist: 'Daily CheckList',
|
dataToolScopeChecklist: 'Daily CheckList',
|
||||||
languages: 'Languages',
|
languages: 'Languages',
|
||||||
@@ -973,8 +1019,6 @@ export const systemWordingMessages = {
|
|||||||
skills: 'Specialities',
|
skills: 'Specialities',
|
||||||
environments: 'Ideal Habitats',
|
environments: 'Ideal Habitats',
|
||||||
favoriteThings: 'Favourites / tags',
|
favoriteThings: 'Favourites / tags',
|
||||||
itemCategories: 'Item categories',
|
|
||||||
itemUsages: 'Item usages',
|
|
||||||
acquisitionMethods: 'Acquisition methods',
|
acquisitionMethods: 'Acquisition methods',
|
||||||
maps: 'Maps',
|
maps: 'Maps',
|
||||||
lifeCategories: 'Life categories',
|
lifeCategories: 'Life categories',
|
||||||
@@ -1151,9 +1195,12 @@ export const systemWordingMessages = {
|
|||||||
environmentRequired: 'Ideal Habitat is required',
|
environmentRequired: 'Ideal Habitat is required',
|
||||||
skillNoDrop: 'This speciality cannot have a drop item',
|
skillNoDrop: 'This speciality cannot have a drop item',
|
||||||
habitatNameRequired: 'Habitat name is required',
|
habitatNameRequired: 'Habitat name is required',
|
||||||
|
itemDisplayIdRequired: 'Item Display ID is required',
|
||||||
usageRequired: 'Usage is required',
|
usageRequired: 'Usage is required',
|
||||||
itemNameRequired: 'Item name is required',
|
itemNameRequired: 'Item name is required',
|
||||||
categoryRequired: 'Category is required',
|
categoryRequired: 'Category is required',
|
||||||
|
artifactDisplayIdRequired: 'Ancient Artifact Display ID is required',
|
||||||
|
artifactNameRequired: 'Ancient Artifact name is required',
|
||||||
recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free',
|
recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free',
|
||||||
itemRequired: 'Item is required',
|
itemRequired: 'Item is required',
|
||||||
recipeFreeItem: 'This item is marked as recipe-free',
|
recipeFreeItem: 'This item is marked as recipe-free',
|
||||||
@@ -1246,6 +1293,8 @@ export const systemWordingMessages = {
|
|||||||
habitats: '栖息地',
|
habitats: '栖息地',
|
||||||
eventHabitats: 'Event Habitats',
|
eventHabitats: 'Event Habitats',
|
||||||
items: '物品',
|
items: '物品',
|
||||||
|
eventItems: 'Event Items',
|
||||||
|
ancientArtifacts: 'Ancient Artifacts',
|
||||||
recipes: '材料单',
|
recipes: '材料单',
|
||||||
automation: '自动化',
|
automation: '自动化',
|
||||||
dish: '料理',
|
dish: '料理',
|
||||||
@@ -1277,9 +1326,10 @@ export const systemWordingMessages = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、材料单、每日清单和 Life 社区动态。',
|
siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。',
|
||||||
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
||||||
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
||||||
|
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
|
||||||
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
|
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
|
||||||
recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。'
|
recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。'
|
||||||
},
|
},
|
||||||
@@ -1384,6 +1434,14 @@ export const systemWordingMessages = {
|
|||||||
title: '物品',
|
title: '物品',
|
||||||
description: '按分类、用途、入手方式、自定义和标签浏览物品。'
|
description: '按分类、用途、入手方式、自定义和标签浏览物品。'
|
||||||
},
|
},
|
||||||
|
eventItems: {
|
||||||
|
title: 'Event Items',
|
||||||
|
description: '浏览限时活动物品,并维护独立的 Display ID 与共享分类。'
|
||||||
|
},
|
||||||
|
ancientArtifacts: {
|
||||||
|
title: 'Ancient Artifacts',
|
||||||
|
description: '浏览 Lost Relics 和 Fossils 的标签、介绍与 Wiki 历史。'
|
||||||
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
title: '材料单',
|
title: '材料单',
|
||||||
description: '查找结果物品、需要材料和入手方式。'
|
description: '查找结果物品、需要材料和入手方式。'
|
||||||
@@ -1781,6 +1839,7 @@ export const systemWordingMessages = {
|
|||||||
items: {
|
items: {
|
||||||
title: '物品',
|
title: '物品',
|
||||||
subtitle: '按分类、用途、标签查看物品。',
|
subtitle: '按分类、用途、标签查看物品。',
|
||||||
|
kicker: '物品',
|
||||||
detailKicker: 'Item Detail',
|
detailKicker: 'Item Detail',
|
||||||
detailSubtitle: '物品详情',
|
detailSubtitle: '物品详情',
|
||||||
editKicker: 'Item Edit',
|
editKicker: 'Item Edit',
|
||||||
@@ -1791,6 +1850,8 @@ export const systemWordingMessages = {
|
|||||||
loadingList: '正在加载列表',
|
loadingList: '正在加载列表',
|
||||||
loadingDetail: '正在加载物品详情',
|
loadingDetail: '正在加载物品详情',
|
||||||
loadingEdit: '正在加载物品编辑内容',
|
loadingEdit: '正在加载物品编辑内容',
|
||||||
|
displayId: 'Display ID',
|
||||||
|
description: '介绍',
|
||||||
category: '分类',
|
category: '分类',
|
||||||
usage: '用途',
|
usage: '用途',
|
||||||
tags: '标签',
|
tags: '标签',
|
||||||
@@ -1811,6 +1872,35 @@ export const systemWordingMessages = {
|
|||||||
searchMethods: '搜索入手方式',
|
searchMethods: '搜索入手方式',
|
||||||
searchTags: '搜索标签'
|
searchTags: '搜索标签'
|
||||||
},
|
},
|
||||||
|
eventItems: {
|
||||||
|
title: 'Event Items',
|
||||||
|
subtitle: '按分类、用途、标签查看活动物品。',
|
||||||
|
kicker: 'Event Items',
|
||||||
|
detailKicker: 'Event Item Detail',
|
||||||
|
editSubtitle: '维护 Event Item 分类、用途、入手方式、自定义和标签。',
|
||||||
|
newTitle: '新增 Event Item'
|
||||||
|
},
|
||||||
|
ancientArtifacts: {
|
||||||
|
title: 'Ancient Artifacts',
|
||||||
|
subtitle: '按遗物、化石分类和标签查看 Ancient Artifacts。',
|
||||||
|
kicker: 'Ancient Artifacts',
|
||||||
|
detailKicker: 'Ancient Artifact Detail',
|
||||||
|
detailSubtitle: 'Ancient Artifact 详情',
|
||||||
|
editKicker: 'Ancient Artifact Edit',
|
||||||
|
editSubtitle: '维护 Ancient Artifact Display ID、图片、介绍、分类、标签和翻译。',
|
||||||
|
newTitle: '新增 Ancient Artifact',
|
||||||
|
editTitle: '编辑 {name}',
|
||||||
|
fallbackName: 'Ancient Artifact',
|
||||||
|
loadingList: '正在加载 Ancient Artifact 列表',
|
||||||
|
loadingDetail: '正在加载 Ancient Artifact 详情',
|
||||||
|
loadingEdit: '正在加载 Ancient Artifact 编辑内容',
|
||||||
|
displayId: 'Display ID',
|
||||||
|
description: '介绍',
|
||||||
|
category: '分类',
|
||||||
|
tags: '标签',
|
||||||
|
searchCategory: '搜索分类',
|
||||||
|
searchTags: '搜索标签'
|
||||||
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
title: '材料单',
|
title: '材料单',
|
||||||
subtitle: '按分类、用途、标签查看材料单。',
|
subtitle: '按分类、用途、标签查看材料单。',
|
||||||
@@ -2035,6 +2125,7 @@ export const systemWordingMessages = {
|
|||||||
checklist: 'CheckList',
|
checklist: 'CheckList',
|
||||||
pokemonList: 'Pokemon 列表',
|
pokemonList: 'Pokemon 列表',
|
||||||
itemList: '物品列表',
|
itemList: '物品列表',
|
||||||
|
ancientArtifactList: 'Ancient Artifact 列表',
|
||||||
recipeList: '材料单列表',
|
recipeList: '材料单列表',
|
||||||
habitatList: '栖息地列表',
|
habitatList: '栖息地列表',
|
||||||
dataTools: '数据工具',
|
dataTools: '数据工具',
|
||||||
@@ -2059,6 +2150,7 @@ export const systemWordingMessages = {
|
|||||||
dataToolScopePokemon: 'Pokemon',
|
dataToolScopePokemon: 'Pokemon',
|
||||||
dataToolScopeHabitats: '栖息地',
|
dataToolScopeHabitats: '栖息地',
|
||||||
dataToolScopeItems: '物品',
|
dataToolScopeItems: '物品',
|
||||||
|
dataToolScopeArtifacts: 'Ancient Artifacts',
|
||||||
dataToolScopeRecipes: '材料单',
|
dataToolScopeRecipes: '材料单',
|
||||||
dataToolScopeChecklist: '每日 CheckList',
|
dataToolScopeChecklist: '每日 CheckList',
|
||||||
languages: '语言',
|
languages: '语言',
|
||||||
@@ -2146,8 +2238,6 @@ export const systemWordingMessages = {
|
|||||||
skills: '特长',
|
skills: '特长',
|
||||||
environments: '喜欢的环境',
|
environments: '喜欢的环境',
|
||||||
favoriteThings: '喜欢的东西 / 标签',
|
favoriteThings: '喜欢的东西 / 标签',
|
||||||
itemCategories: '物品分类',
|
|
||||||
itemUsages: '物品用途',
|
|
||||||
acquisitionMethods: '入手方式',
|
acquisitionMethods: '入手方式',
|
||||||
maps: '地图',
|
maps: '地图',
|
||||||
lifeCategories: 'Life Categories',
|
lifeCategories: 'Life Categories',
|
||||||
@@ -2324,9 +2414,12 @@ export const systemWordingMessages = {
|
|||||||
environmentRequired: '请选择喜欢的环境',
|
environmentRequired: '请选择喜欢的环境',
|
||||||
skillNoDrop: '这个特长不能设置掉落物',
|
skillNoDrop: '这个特长不能设置掉落物',
|
||||||
habitatNameRequired: '请输入栖息地名称',
|
habitatNameRequired: '请输入栖息地名称',
|
||||||
|
itemDisplayIdRequired: '请输入物品 Display ID',
|
||||||
usageRequired: '请选择用途',
|
usageRequired: '请选择用途',
|
||||||
itemNameRequired: '请输入物品名称',
|
itemNameRequired: '请输入物品名称',
|
||||||
categoryRequired: '请选择分类',
|
categoryRequired: '请选择分类',
|
||||||
|
artifactDisplayIdRequired: '请输入 Ancient Artifact Display ID',
|
||||||
|
artifactNameRequired: '请输入 Ancient Artifact 名称',
|
||||||
recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单',
|
recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单',
|
||||||
itemRequired: '请选择物品',
|
itemRequired: '请选择物品',
|
||||||
recipeFreeItem: '这个物品已标记为无材料单',
|
recipeFreeItem: '这个物品已标记为无材料单',
|
||||||
|
|||||||
Reference in New Issue
Block a user