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。
|
||||
- 所有人都可以浏览 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 和正在准备中的分区。
|
||||
- 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。
|
||||
|
||||
@@ -53,10 +53,9 @@
|
||||
- Pokemon Types
|
||||
- 喜欢的环境
|
||||
- 喜欢的东西 / 标签
|
||||
- 物品分类
|
||||
- 物品用途
|
||||
- 入手方式
|
||||
- 物品
|
||||
- Ancient Artifacts
|
||||
- 地图
|
||||
- 栖息地
|
||||
- 每日 CheckList Task
|
||||
@@ -65,7 +64,7 @@
|
||||
- 支持翻译的字段:
|
||||
- `name`
|
||||
- `title`
|
||||
- `details`:仅 Pokemon 介绍使用
|
||||
- `details`:Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
|
||||
- `genus`:仅 Pokemon Genus 使用
|
||||
- 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。
|
||||
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
|
||||
@@ -196,6 +195,7 @@
|
||||
- Pokemon
|
||||
- Habitats
|
||||
- Items
|
||||
- Ancient Artifacts
|
||||
- Recipes
|
||||
- Daily CheckList
|
||||
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes,前端确认内容也必须显示 Recipes。
|
||||
@@ -204,6 +204,7 @@
|
||||
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
||||
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
||||
- Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。
|
||||
- Wipe Ancient Artifacts 会删除 Ancient Artifacts、标签关联、实体翻译、编辑历史和实体讨论评论。
|
||||
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
||||
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
||||
- 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。
|
||||
@@ -378,7 +379,7 @@
|
||||
|
||||
## 全局配置数据
|
||||
|
||||
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。
|
||||
以下配置项都支持创建、编辑、删除、翻译和拖拽排序。物品分类、物品用途和 Ancient Artifacts 分类是代码维护的系统固定列表,不属于可配置数据。
|
||||
|
||||
### 特长
|
||||
|
||||
@@ -404,16 +405,6 @@
|
||||
- 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 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。
|
||||
|
||||
物品列表功能:
|
||||
|
||||
- 搜索
|
||||
- 按分类展示为标签页
|
||||
- 按用途筛选
|
||||
- 按标签筛选
|
||||
- 按自定义排序展示
|
||||
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、名称和分类;不展示标签、入手方式或编辑元信息。
|
||||
- 按 Display ID 和自定义排序展示
|
||||
- 物品列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,只展示物品图标、`#Display ID 名称` 和分类;不展示标签、入手方式或编辑元信息。
|
||||
- 有用途的物品在卡片左上角以斜 Ribbon 展示用途名称。
|
||||
- 已配置图标时,物品卡片展示图标缩略图;未配置图标时保留默认物品标记。
|
||||
|
||||
@@ -583,6 +598,8 @@ Pokemon 详情页展示:
|
||||
- 基本信息
|
||||
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
||||
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `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 详情页展示:
|
||||
|
||||
- 独立于物品列表展示
|
||||
- 按结果物品分类展示
|
||||
- 按自定义排序展示
|
||||
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、名称和分类;不展示编辑元信息。
|
||||
- 按结果物品 Display ID 和自定义排序展示
|
||||
- 材料单列表卡片使用与 Pokemon 列表一致的居中图鉴式布局,按结果物品展示图标、`#Display ID 名称` 和分类;不展示编辑元信息。
|
||||
- 有用途的结果物品在卡片左上角以斜 Ribbon 展示用途名称。
|
||||
- Create Recipe 按钮展示在结果物品名称下方;已有材料单的卡片保留同等按钮空间但不显示按钮;标记为无材料单的物品展示禁用按钮;可创建材料单的物品展示可点击按钮并进入创建流程。
|
||||
|
||||
@@ -820,6 +873,7 @@ API 暴露边界:
|
||||
- 管理入口在全局侧边栏中保持单一 Admin 入口,`/admin` 内部使用页面内二级菜单分组组织管理模块:
|
||||
- 配置:System config。
|
||||
- 内容:Daily CheckList、Pokemon、物品、材料单、栖息地的维护、排序或删除入口,以及 Data Tools。
|
||||
- 内容管理包含 Items、Event Items 与 Ancient Artifacts;Items / Event Items 使用同一物品数据模型,通过 `is_event_item` 拆分入口。
|
||||
- 本地化:Languages、System wordings。
|
||||
- 访问权限:Users、Roles、Permissions、Rate limits。
|
||||
- 登录用户的侧边栏账号入口进入 `/profile`;User Profile 属于账号入口,不作为 Wiki 主内容导航项。
|
||||
@@ -837,7 +891,10 @@ API 暴露边界:
|
||||
- `/event-habitats/new`
|
||||
- `/habitats/:id/edit`
|
||||
- `/items/new`
|
||||
- `/event-items/new`
|
||||
- `/items/:id/edit`
|
||||
- `/ancient-artifacts/new`
|
||||
- `/ancient-artifacts/:id/edit`
|
||||
- `/recipes/new`
|
||||
- `/recipes/:id/edit`
|
||||
- Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。
|
||||
@@ -859,6 +916,8 @@ API 暴露边界:
|
||||
- `/habitats`
|
||||
- `/event-habitats`
|
||||
- `/items`
|
||||
- `/event-items`
|
||||
- `/ancient-artifacts`
|
||||
- `/recipes`
|
||||
- `/checklist`
|
||||
- `/life`
|
||||
@@ -883,8 +942,10 @@ API 暴露边界:
|
||||
- `GET /api/pokemon/:id`
|
||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器
|
||||
- `GET /api/habitats/:id`
|
||||
- `GET /api/items`
|
||||
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;未传时返回全部 Items 以兼容管理端和实体选择器
|
||||
- `GET /api/items/:id`
|
||||
- `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选
|
||||
- `GET /api/ancient-artifacts/:id`
|
||||
- `GET /api/recipes`
|
||||
- `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`。
|
||||
@@ -893,7 +954,7 @@ API 暴露边界:
|
||||
- `GET /api/users/:id/life-posts`:分页读取该用户发布过且未删除的 Life Post。
|
||||
- `GET /api/users/:id/reactions`:分页读取该用户设置过 Reaction 且目标未删除的 Life Post。
|
||||
- `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:
|
||||
|
||||
|
||||
@@ -30,10 +30,12 @@ CREATE TABLE IF NOT EXISTS entity_translations (
|
||||
'item-usages',
|
||||
'acquisition-methods',
|
||||
'items',
|
||||
'ancient-artifacts',
|
||||
'maps',
|
||||
'habitats',
|
||||
'daily-checklist-items',
|
||||
'life-tags'
|
||||
'life-tags',
|
||||
'game-versions'
|
||||
)
|
||||
),
|
||||
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
|
||||
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 (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
email text NOT NULL UNIQUE,
|
||||
@@ -241,6 +267,11 @@ VALUES
|
||||
('items.delete', 'Delete items', 'Delete item records.', 'Items', true),
|
||||
('items.order', 'Order items', 'Reorder item records.', '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.update', 'Update recipes', 'Edit 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.order',
|
||||
'items.upload',
|
||||
'ancient-artifacts.create',
|
||||
'ancient-artifacts.update',
|
||||
'ancient-artifacts.delete',
|
||||
'ancient-artifacts.order',
|
||||
'ancient-artifacts.upload',
|
||||
'recipes.create',
|
||||
'recipes.update',
|
||||
'recipes.delete',
|
||||
@@ -395,6 +431,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'items.update',
|
||||
'items.order',
|
||||
'items.upload',
|
||||
'ancient-artifacts.create',
|
||||
'ancient-artifacts.update',
|
||||
'ancient-artifacts.order',
|
||||
'ancient-artifacts.upload',
|
||||
'recipes.create',
|
||||
'recipes.update',
|
||||
'recipes.order',
|
||||
@@ -416,6 +456,31 @@ WHERE r.key = 'editor'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
JOIN permissions p ON p.key = ANY (ARRAY[
|
||||
'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)
|
||||
SELECT r.id, p.id
|
||||
FROM roles r
|
||||
@@ -798,8 +863,12 @@ CREATE TABLE IF NOT EXISTS acquisition_methods (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS items (
|
||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
display_id integer NOT NULL CHECK (display_id > 0),
|
||||
name text NOT NULL UNIQUE,
|
||||
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),
|
||||
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,
|
||||
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CHECK (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()
|
||||
);
|
||||
|
||||
@@ -842,6 +940,12 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
|
||||
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 (
|
||||
pokemon_id integer NOT NULL,
|
||||
skill_id integer NOT NULL,
|
||||
@@ -899,6 +1003,116 @@ CREATE TABLE IF NOT EXISTS habitat_pokemon (
|
||||
ALTER TABLE life_tags
|
||||
ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
|
||||
|
||||
ALTER TABLE items
|
||||
ADD COLUMN IF NOT EXISTS display_id integer,
|
||||
ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS category_key text,
|
||||
ADD COLUMN IF NOT EXISTS usage_key text;
|
||||
|
||||
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 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);
|
||||
@@ -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 acquisition_methods_sort_order_idx ON acquisition_methods(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS items_sort_order_idx ON items(sort_order, id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS items_display_event_item_key ON items(display_id, is_event_item);
|
||||
CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, display_id, sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
|
||||
CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(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 (
|
||||
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_name text NOT NULL,
|
||||
path text NOT NULL UNIQUE,
|
||||
@@ -946,6 +1164,14 @@ CREATE TABLE IF NOT EXISTS entity_image_uploads (
|
||||
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
|
||||
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 (
|
||||
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,
|
||||
parent_comment_id integer REFERENCES entity_discussion_comments(id) ON DELETE CASCADE,
|
||||
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
|
||||
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
|
||||
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 DbClient = PoolClient;
|
||||
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
|
||||
type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||
type DataToolScopeSummary = {
|
||||
scope: DataToolScope;
|
||||
count: number;
|
||||
@@ -48,6 +48,7 @@ type EntityType =
|
||||
| 'item-usages'
|
||||
| 'acquisition-methods'
|
||||
| 'items'
|
||||
| 'ancient-artifacts'
|
||||
| 'maps'
|
||||
| 'habitats'
|
||||
| 'daily-checklist-items'
|
||||
@@ -59,8 +60,6 @@ type ConfigType =
|
||||
| 'skills'
|
||||
| 'environments'
|
||||
| 'favorite-things'
|
||||
| 'item-categories'
|
||||
| 'item-usages'
|
||||
| 'acquisition-methods'
|
||||
| 'maps'
|
||||
| 'life-tags'
|
||||
@@ -74,7 +73,7 @@ type ConfigDefinition = {
|
||||
hasRateable?: boolean;
|
||||
hasChangeLog?: boolean;
|
||||
};
|
||||
type SortableContentType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type SortableContentType = 'pokemon' | 'items' | 'ancient-artifacts' | 'recipes' | 'habitats';
|
||||
type SortableContentDefinition = {
|
||||
table: string;
|
||||
entityType: SortableContentType;
|
||||
@@ -171,10 +170,14 @@ type PokemonCsvData = {
|
||||
};
|
||||
|
||||
type ItemPayload = {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
translations: TranslationInput;
|
||||
categoryId: number;
|
||||
categoryKey: string;
|
||||
usageId: number | null;
|
||||
usageKey: string | null;
|
||||
dyeable: boolean;
|
||||
dualDyeable: boolean;
|
||||
patternEditable: boolean;
|
||||
@@ -185,6 +188,17 @@ type ItemPayload = {
|
||||
imagePath: string;
|
||||
};
|
||||
|
||||
type AncientArtifactPayload = {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
translations: TranslationInput;
|
||||
categoryId: number;
|
||||
categoryKey: string;
|
||||
tagIds: number[];
|
||||
imagePath: string;
|
||||
};
|
||||
|
||||
type RecipePayload = {
|
||||
itemId: number;
|
||||
acquisitionMethodIds: number[];
|
||||
@@ -208,7 +222,7 @@ type LifeCommentPayload = {
|
||||
languageCode: string | null;
|
||||
};
|
||||
|
||||
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
||||
type DiscussionEntityDefinition = {
|
||||
table: string;
|
||||
};
|
||||
@@ -436,7 +450,9 @@ type PokemonChangeSource = {
|
||||
favorite_things: Array<{ name: string }>;
|
||||
} & TranslationChangeSource;
|
||||
type ItemChangeSource = {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
isEventItem: boolean;
|
||||
image: EntityImageValue | null;
|
||||
category: { name: string };
|
||||
@@ -446,6 +462,14 @@ type ItemChangeSource = {
|
||||
acquisitionMethods: Array<{ name: string }>;
|
||||
tags: Array<{ name: string }>;
|
||||
} & TranslationChangeSource;
|
||||
type AncientArtifactChangeSource = {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
image: EntityImageValue | null;
|
||||
category: { name: string };
|
||||
tags: Array<{ name: string }>;
|
||||
} & TranslationChangeSource;
|
||||
type HabitatChangeSource = {
|
||||
name: string;
|
||||
isEventItem: boolean;
|
||||
@@ -491,13 +515,45 @@ const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
|
||||
{ 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> = {
|
||||
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
|
||||
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
|
||||
environments: { table: 'environments', entityType: 'environments' },
|
||||
'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' },
|
||||
maps: { table: 'maps', entityType: 'maps' },
|
||||
'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> = {
|
||||
pokemon: { table: 'pokemon', entityType: 'pokemon' },
|
||||
items: { table: 'items', entityType: 'items' },
|
||||
'ancient-artifacts': { table: 'ancient_artifacts', entityType: 'ancient-artifacts' },
|
||||
recipes: { table: 'recipes', entityType: 'recipes' },
|
||||
habitats: { table: 'habitats', entityType: 'habitats' }
|
||||
};
|
||||
@@ -515,7 +572,8 @@ const discussionEntityDefinitions: Record<DiscussionEntityType, DiscussionEntity
|
||||
pokemon: { table: 'pokemon' },
|
||||
items: { table: 'items' },
|
||||
recipes: { table: 'recipes' },
|
||||
habitats: { table: 'habitats' }
|
||||
habitats: { table: 'habitats' },
|
||||
'ancient-artifacts': { table: 'ancient_artifacts' }
|
||||
};
|
||||
|
||||
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')}`);
|
||||
}
|
||||
|
||||
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 }>> {
|
||||
const name = localizedName('life-tags', 'lc', locale);
|
||||
return query(
|
||||
@@ -821,7 +925,7 @@ function cleanOptionalText(value: unknown): string {
|
||||
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);
|
||||
if (imagePath === '') {
|
||||
return '';
|
||||
@@ -2030,17 +2134,17 @@ async function itemEditChanges(
|
||||
after: ItemPayload
|
||||
): Promise<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 tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
|
||||
|
||||
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, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
|
||||
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
|
||||
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
|
||||
pushChange(changes, 'Category', before.category.name, systemListNameByKey(itemCategoryOptions, after.categoryKey));
|
||||
pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey));
|
||||
pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable));
|
||||
pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable));
|
||||
pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable));
|
||||
@@ -2051,6 +2155,24 @@ async function itemEditChanges(
|
||||
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(
|
||||
client: DbClient,
|
||||
before: HabitatChangeSource,
|
||||
@@ -2221,8 +2343,6 @@ export async function getOptions(locale = defaultLocale) {
|
||||
skills,
|
||||
environments,
|
||||
favoriteThings,
|
||||
itemCategories,
|
||||
itemUsages,
|
||||
acquisitionMethods,
|
||||
maps,
|
||||
lifeCategories,
|
||||
@@ -2232,8 +2352,6 @@ export async function getOptions(locale = defaultLocale) {
|
||||
skillOptions(locale),
|
||||
optionSelect('environments', 'environments', 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('maps', 'maps', locale),
|
||||
lifeCategoryOptions(locale),
|
||||
@@ -2245,8 +2363,9 @@ export async function getOptions(locale = defaultLocale) {
|
||||
skills,
|
||||
environments,
|
||||
favoriteThings,
|
||||
itemCategories,
|
||||
itemUsages,
|
||||
itemCategories: systemListOptions(itemCategoryOptions, locale),
|
||||
itemUsages: systemListOptions(itemUsageOptions, locale),
|
||||
ancientArtifactCategories: systemListOptions(ancientArtifactCategoryOptions, locale),
|
||||
acquisitionMethods,
|
||||
itemTags: favoriteThings,
|
||||
maps,
|
||||
@@ -3379,6 +3498,7 @@ export async function listUserCommentActivities(
|
||||
const itemName = localizedName('items', 'i', locale);
|
||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||
const habitatName = localizedName('habitats', 'h', locale);
|
||||
const artifactName = localizedName('ancient-artifacts', 'a', locale);
|
||||
const params: unknown[] = [user.id];
|
||||
const outerConditions: string[] = [];
|
||||
|
||||
@@ -3444,6 +3564,7 @@ export async function listUserCommentActivities(
|
||||
WHEN 'items' THEN ${itemName}
|
||||
WHEN 'recipes' THEN ${recipeItemName}
|
||||
WHEN 'habitats' THEN ${habitatName}
|
||||
WHEN 'ancient-artifacts' THEN ${artifactName}
|
||||
ELSE ''
|
||||
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 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 ancient_artifacts a ON edc.entity_type = 'ancient-artifacts' AND a.id = edc.entity_id
|
||||
WHERE edc.created_by_user_id = $1
|
||||
AND edc.deleted_at IS NULL
|
||||
AND edc.ai_moderation_status = 'approved'
|
||||
@@ -4434,6 +4556,11 @@ export async function reorderItems(payload: Record<string, unknown>, userId: num
|
||||
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) {
|
||||
await reorderContent('recipes', payload, userId);
|
||||
return listRecipes({}, locale);
|
||||
@@ -4507,7 +4634,6 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
const habitatName = localizedName('habitats', 'h', locale);
|
||||
const mapName = localizedName('maps', 'm', locale);
|
||||
const itemName = localizedName('items', 'i', locale);
|
||||
const categoryName = localizedName('item-categories', 'c', locale);
|
||||
const tagName = localizedName('favorite-things', 'ft', locale);
|
||||
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
|
||||
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
|
||||
@@ -4551,16 +4677,15 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
i.id,
|
||||
${itemName} AS name,
|
||||
${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
|
||||
FROM pokemon_favorite_things pft
|
||||
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 items i ON i.id = ift.item_id
|
||||
JOIN item_categories c ON c.id = i.category_id
|
||||
WHERE pft.pokemon_id = $1
|
||||
GROUP BY i.id, i.name, i.image_path, i.sort_order, c.id, c.name, c.sort_order
|
||||
ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')}
|
||||
GROUP BY i.id, i.name, i.image_path, i.category_key, i.sort_order
|
||||
ORDER BY i.category_key, ${orderByEntity('i')}
|
||||
`,
|
||||
[id]
|
||||
),
|
||||
@@ -5190,21 +5315,26 @@ export async function deleteHabitat(id: number, userId: number) {
|
||||
|
||||
function itemProjection(locale: string): string {
|
||||
const itemName = localizedName('items', 'i', locale);
|
||||
const categoryName = localizedName('item-categories', 'c', locale);
|
||||
const usageName = localizedName('item-usages', 'u', locale);
|
||||
const itemDetails = localizedField('items', 'i.id', 'i.details', 'details', locale);
|
||||
const tagName = localizedName('favorite-things', 't', locale);
|
||||
|
||||
return `
|
||||
SELECT
|
||||
i.id,
|
||||
i.display_id AS "displayId",
|
||||
${itemName} AS name,
|
||||
i.name AS "baseName",
|
||||
${itemDetails} AS details,
|
||||
i.details AS "baseDetails",
|
||||
i.is_event_item AS "isEventItem",
|
||||
${translationsSelect('items', 'i.id')} AS translations,
|
||||
${auditSelect('i', 'item_created_user', 'item_updated_user')},
|
||||
${uploadedImageJson('i.image_path')} AS image,
|
||||
json_build_object('id', c.id, 'name', ${categoryName}) AS category,
|
||||
CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage,
|
||||
${systemListJsonSql('i.category_key', itemCategoryOptions, locale)} AS category,
|
||||
CASE
|
||||
WHEN i.usage_key IS NULL THEN NULL
|
||||
ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)}
|
||||
END AS usage,
|
||||
json_build_object(
|
||||
'dyeable', i.dyeable,
|
||||
'dualDyeable', i.dual_dyeable,
|
||||
@@ -5234,8 +5364,6 @@ function itemProjection(locale: string): string {
|
||||
)
|
||||
END AS recipe
|
||||
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 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
|
||||
@@ -5248,23 +5376,35 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale
|
||||
const conditions: string[] = [];
|
||||
const categoryId = Number(asString(paramsQuery.categoryId));
|
||||
const usageId = Number(asString(paramsQuery.usageId));
|
||||
const isEventItem = asString(paramsQuery.isEventItem);
|
||||
const tagIds = parseIdList(asString(paramsQuery.tagIds));
|
||||
const search = asString(paramsQuery.search)?.trim();
|
||||
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) {
|
||||
params.push(`%${search}%`);
|
||||
conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`);
|
||||
}
|
||||
|
||||
if (Number.isInteger(categoryId) && categoryId > 0) {
|
||||
params.push(categoryId);
|
||||
conditions.push(`i.category_id = $${params.length}`);
|
||||
if (isEventItem === 'true' || isEventItem === 'false') {
|
||||
params.push(isEventItem === 'true');
|
||||
conditions.push(`i.is_event_item = $${params.length}`);
|
||||
}
|
||||
|
||||
if (Number.isInteger(usageId) && usageId > 0) {
|
||||
params.push(usageId);
|
||||
conditions.push(`i.usage_id = $${params.length}`);
|
||||
if (categoryOption) {
|
||||
params.push(categoryOption.key);
|
||||
conditions.push(`i.category_key = $${params.length}`);
|
||||
}
|
||||
|
||||
if (usageOption) {
|
||||
params.push(usageOption.key);
|
||||
conditions.push(`i.usage_key = $${params.length}`);
|
||||
}
|
||||
|
||||
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 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 ${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 i.display_id, ${orderByEntity('i')}`;
|
||||
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 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 habitatName = localizedName('habitats', 'h', locale);
|
||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||
@@ -5342,18 +5480,17 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
), '[]'::json) AS materials,
|
||||
json_build_object(
|
||||
'id', result_item.id,
|
||||
'displayId', result_item.display_id,
|
||||
'name', ${resultItemName},
|
||||
'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
|
||||
WHEN result_usage.id IS NULL THEN NULL
|
||||
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
|
||||
WHEN result_item.usage_key IS NULL THEN NULL
|
||||
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
||||
END
|
||||
) AS item
|
||||
FROM recipes r
|
||||
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')}
|
||||
WHERE r.item_id = $1
|
||||
`,
|
||||
@@ -5442,15 +5579,22 @@ export async function getItem(id: number, locale = defaultLocale) {
|
||||
}
|
||||
|
||||
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
|
||||
? null
|
||||
: 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 {
|
||||
displayId: requirePositiveInteger(payload.displayId, 'server.validation.itemDisplayIdRequired'),
|
||||
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
||||
translations: cleanTranslations(payload.translations, ['name']),
|
||||
categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'),
|
||||
details: cleanOptionalText(payload.details),
|
||||
translations: cleanTranslations(payload.translations, ['name', 'details']),
|
||||
categoryId,
|
||||
categoryKey: category.key,
|
||||
usageId,
|
||||
usageKey: usage?.key ?? null,
|
||||
dyeable: Boolean(payload.dyeable),
|
||||
dualDyeable: Boolean(payload.dualDyeable),
|
||||
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 }>(
|
||||
`
|
||||
INSERT INTO items (
|
||||
display_id,
|
||||
name,
|
||||
category_id,
|
||||
usage_id,
|
||||
details,
|
||||
category_key,
|
||||
usage_key,
|
||||
dyeable,
|
||||
dual_dyeable,
|
||||
pattern_editable,
|
||||
@@ -5513,13 +5659,15 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
created_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
|
||||
`,
|
||||
[
|
||||
cleanPayload.displayId,
|
||||
cleanPayload.name,
|
||||
cleanPayload.categoryId,
|
||||
cleanPayload.usageId,
|
||||
cleanPayload.details,
|
||||
cleanPayload.categoryKey,
|
||||
cleanPayload.usageKey,
|
||||
cleanPayload.dyeable,
|
||||
cleanPayload.dualDyeable,
|
||||
cleanPayload.patternEditable,
|
||||
@@ -5533,7 +5681,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
||||
const itemId = result.rows[0].id;
|
||||
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
||||
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);
|
||||
return itemId;
|
||||
});
|
||||
@@ -5549,23 +5697,27 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
|
||||
const result = await client.query(
|
||||
`
|
||||
UPDATE items
|
||||
SET name = $1,
|
||||
category_id = $2,
|
||||
usage_id = $3,
|
||||
dyeable = $4,
|
||||
dual_dyeable = $5,
|
||||
pattern_editable = $6,
|
||||
no_recipe = $7,
|
||||
is_event_item = $8,
|
||||
image_path = $9,
|
||||
updated_by_user_id = $10,
|
||||
SET display_id = $1,
|
||||
name = $2,
|
||||
details = $3,
|
||||
category_key = $4,
|
||||
usage_key = $5,
|
||||
dyeable = $6,
|
||||
dual_dyeable = $7,
|
||||
pattern_editable = $8,
|
||||
no_recipe = $9,
|
||||
is_event_item = $10,
|
||||
image_path = $11,
|
||||
updated_by_user_id = $12,
|
||||
updated_at = now()
|
||||
WHERE id = $11
|
||||
WHERE id = $13
|
||||
`,
|
||||
[
|
||||
cleanPayload.displayId,
|
||||
cleanPayload.name,
|
||||
cleanPayload.categoryId,
|
||||
cleanPayload.usageId,
|
||||
cleanPayload.details,
|
||||
cleanPayload.categoryKey,
|
||||
cleanPayload.usageKey,
|
||||
cleanPayload.dyeable,
|
||||
cleanPayload.dualDyeable,
|
||||
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 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) : [];
|
||||
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
||||
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) {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = [];
|
||||
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 materialItemName = localizedName('items', 'i', locale);
|
||||
|
||||
if (Number.isInteger(categoryId) && categoryId > 0) {
|
||||
params.push(categoryId);
|
||||
conditions.push(`result_item.category_id = $${params.length}`);
|
||||
if (categoryOption) {
|
||||
params.push(categoryOption.key);
|
||||
conditions.push(`result_item.category_key = $${params.length}`);
|
||||
}
|
||||
|
||||
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) {
|
||||
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 materialItemName = localizedName('items', 'i', locale);
|
||||
|
||||
@@ -5670,18 +6015,17 @@ export async function getRecipe(id: number, locale = defaultLocale) {
|
||||
), '[]'::json) AS materials,
|
||||
json_build_object(
|
||||
'id', result_item.id,
|
||||
'displayId', result_item.display_id,
|
||||
'name', ${resultItemName},
|
||||
'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
|
||||
WHEN result_usage.id IS NULL THEN NULL
|
||||
ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName})
|
||||
WHEN result_item.usage_key IS NULL THEN NULL
|
||||
ELSE ${systemListJsonSql('result_item.usage_key', itemUsageOptions, locale)}
|
||||
END
|
||||
) AS item
|
||||
FROM recipes r
|
||||
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')}
|
||||
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> = {
|
||||
pokemon: 'pokemon',
|
||||
habitats: 'habitats',
|
||||
items: 'items',
|
||||
artifacts: 'ancient_artifacts',
|
||||
recipes: 'recipes',
|
||||
checklist: 'daily_checklist_items'
|
||||
};
|
||||
@@ -5839,9 +6184,11 @@ const dataToolColumns = {
|
||||
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
|
||||
items: [
|
||||
'id',
|
||||
'display_id',
|
||||
'name',
|
||||
'category_id',
|
||||
'usage_id',
|
||||
'details',
|
||||
'category_key',
|
||||
'usage_key',
|
||||
'dyeable',
|
||||
'dual_dyeable',
|
||||
'pattern_editable',
|
||||
@@ -5856,6 +6203,20 @@ const dataToolColumns = {
|
||||
],
|
||||
itemAcquisitionMethods: ['item_id', 'acquisition_method_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'],
|
||||
recipeAcquisitionMethods: ['recipe_id', 'acquisition_method_id'],
|
||||
recipeMaterials: ['recipe_id', 'item_id', 'quantity'],
|
||||
@@ -5965,8 +6326,20 @@ async function tableRows(client: DbClient, sql: string, params: unknown[] = []):
|
||||
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 (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;
|
||||
}
|
||||
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> {
|
||||
for (const row of rows) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -5995,7 +6368,16 @@ async function resetIdentity(client: DbClient, tableName: string): 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);
|
||||
}
|
||||
}
|
||||
@@ -6024,6 +6406,12 @@ async function wipeItemsData(client: DbClient): Promise<void> {
|
||||
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> {
|
||||
await deleteGenericEntityRows(client, ['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')) {
|
||||
await wipeRecipesData(client);
|
||||
}
|
||||
if (scopeSet.has('artifacts')) {
|
||||
await wipeAncientArtifactsData(client);
|
||||
}
|
||||
if (scopeSet.has('pokemon')) {
|
||||
await wipePokemonData(client);
|
||||
}
|
||||
@@ -6114,7 +6505,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
|
||||
|
||||
if (scope === 'items') {
|
||||
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'),
|
||||
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'),
|
||||
@@ -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') {
|
||||
return {
|
||||
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> {
|
||||
const itemData = bundle.data.items;
|
||||
const artifactData = bundle.data.artifacts;
|
||||
const pokemonData = bundle.data.pokemon;
|
||||
const habitatData = bundle.data.habitats;
|
||||
const checklistData = bundle.data.checklist;
|
||||
const recipeData = bundle.data.recipes;
|
||||
|
||||
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, 'habitats', dataToolColumns.habitats, dataToolTableRows(habitatData, 'habitats'));
|
||||
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> {
|
||||
const itemData = bundle.data.items;
|
||||
const artifactData = bundle.data.artifacts;
|
||||
const pokemonData = bundle.data.pokemon;
|
||||
const habitatData = bundle.data.habitats;
|
||||
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_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_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
||||
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { initializeDatabase, pool } from './db.ts';
|
||||
import {
|
||||
cleanLocale,
|
||||
createAncientArtifact,
|
||||
createConfig,
|
||||
createDailyChecklistItem,
|
||||
createEntityDiscussionComment,
|
||||
@@ -48,6 +49,7 @@ import {
|
||||
createPokemon,
|
||||
createRecipe,
|
||||
deleteConfig,
|
||||
deleteAncientArtifact,
|
||||
deleteDailyChecklistItem,
|
||||
deleteEntityDiscussionComment,
|
||||
deleteHabitat,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
fetchPokemonData,
|
||||
fetchPokemonImageOptions,
|
||||
getAdminDataToolsSummary,
|
||||
getAncientArtifact,
|
||||
getHabitat,
|
||||
getItem,
|
||||
getOptions,
|
||||
@@ -71,6 +74,7 @@ import {
|
||||
getRecipe,
|
||||
importAdminData,
|
||||
isConfigType,
|
||||
listAncientArtifacts,
|
||||
listEntityDiscussionComments,
|
||||
listConfig,
|
||||
listDailyChecklistItems,
|
||||
@@ -86,6 +90,7 @@ import {
|
||||
listUserLifePosts,
|
||||
listUserReactionActivities,
|
||||
reorderConfig,
|
||||
reorderAncientArtifacts,
|
||||
reorderDailyChecklistItems,
|
||||
reorderHabitats,
|
||||
reorderItems,
|
||||
@@ -98,6 +103,7 @@ import {
|
||||
setLifePostRating,
|
||||
setLifePostReaction,
|
||||
updateConfig,
|
||||
updateAncientArtifact,
|
||||
updateDailyChecklistItem,
|
||||
updateHabitat,
|
||||
updateItem,
|
||||
@@ -1504,7 +1510,13 @@ app.post('/api/uploads/:entityType', async (request, reply) => {
|
||||
}
|
||||
|
||||
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');
|
||||
if (!user) {
|
||||
return;
|
||||
@@ -1643,6 +1655,53 @@ app.delete('/api/items/:id', async (request, reply) => {
|
||||
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) =>
|
||||
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;
|
||||
});
|
||||
|
||||
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) => {
|
||||
const user = await requirePermissionWithRateLimits(request, reply, 'recipes.order', 'wikiWrite');
|
||||
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 { query, queryOne } from './db.ts';
|
||||
|
||||
export type UploadEntityType = 'pokemon' | 'items' | 'habitats';
|
||||
export type UploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
|
||||
|
||||
export type EntityImageUpload = {
|
||||
id: number;
|
||||
@@ -26,7 +26,7 @@ type MultipartField = {
|
||||
value?: unknown;
|
||||
};
|
||||
|
||||
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats']);
|
||||
const uploadEntityTypes = new Set<UploadEntityType>(['pokemon', 'items', 'habitats', 'ancient-artifacts']);
|
||||
const imageMimeTypes = new Map([
|
||||
['image/png', '.png'],
|
||||
['image/jpeg', '.jpg'],
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
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="theme-color" content="#6ccf32" />
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta property="og:title" content="Pokopia Wiki - Pokemon Pokopia Guide" />
|
||||
<meta
|
||||
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: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: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" />
|
||||
<script>
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppShell from './components/AppShell.vue';
|
||||
import {
|
||||
iconAction,
|
||||
iconAdmin,
|
||||
iconArtifact,
|
||||
iconAutomation,
|
||||
iconChecklist,
|
||||
iconClothes,
|
||||
@@ -49,6 +50,8 @@ const navItems = computed(() => {
|
||||
{ label: t('nav.habitats'), to: '/habitats', icon: iconHabitat },
|
||||
{ label: t('nav.eventHabitats'), to: '/event-habitats', icon: iconEvent },
|
||||
{ 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.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
|
||||
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() },
|
||||
|
||||
@@ -16,11 +16,13 @@ const changeLabelKeys: Record<string, string> = {
|
||||
标题: 'pages.checklist.task',
|
||||
'Pokemon ID': 'pages.pokemon.id',
|
||||
'Pokopia ID': 'pages.pokemon.id',
|
||||
'Display ID': 'pages.items.displayId',
|
||||
'Event item': 'common.eventItem',
|
||||
'Event Pokemon': 'pages.pokemon.eventItem',
|
||||
'Event Habitat': 'pages.habitats.eventItem',
|
||||
Genus: 'pages.pokemon.genus',
|
||||
Details: 'pages.pokemon.details',
|
||||
Description: 'pages.items.description',
|
||||
介绍: 'pages.pokemon.details',
|
||||
Image: 'pages.pokemon.image',
|
||||
图片: 'pages.pokemon.image',
|
||||
|
||||
@@ -3,6 +3,7 @@ export type AppIcon = string;
|
||||
export const iconAdd: AppIcon = 'mdi:plus';
|
||||
export const iconAdmin: AppIcon = 'mdi:tune-variant';
|
||||
export const iconAction: AppIcon = 'mdi:gesture-tap-button';
|
||||
export const iconArtifact: AppIcon = 'mdi:diamond-stone';
|
||||
export const iconAutomation: AppIcon = 'mdi:factory';
|
||||
export const iconBack: AppIcon = 'mdi:arrow-left';
|
||||
export const iconCancel: AppIcon = 'mdi:close';
|
||||
|
||||
@@ -6,6 +6,8 @@ import HabitatList from '../views/HabitatList.vue';
|
||||
import HabitatDetail from '../views/HabitatDetail.vue';
|
||||
import ItemsList from '../views/ItemsList.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 RecipeDetail from '../views/RecipeDetail.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: '/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',
|
||||
name: 'item-new',
|
||||
component: ItemsList,
|
||||
props: { eventOnly: false },
|
||||
meta: {
|
||||
requiredPermission: 'items.create',
|
||||
editorModal: 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',
|
||||
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: '/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/new',
|
||||
|
||||
@@ -117,7 +117,7 @@ export interface EntityImageUpload extends EntityImage {
|
||||
uploadedBy: UserSummary | null;
|
||||
}
|
||||
|
||||
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats';
|
||||
export type ImageUploadEntityType = 'pokemon' | 'items' | 'habitats' | 'ancient-artifacts';
|
||||
|
||||
export interface PokemonImage extends EntityImage {
|
||||
style: string;
|
||||
@@ -246,6 +246,7 @@ export interface HabitatUsage {
|
||||
}
|
||||
|
||||
export interface RecipeResultItem extends NamedEntity {
|
||||
displayId: number;
|
||||
image?: EntityImage | null;
|
||||
category?: NamedEntity;
|
||||
usage?: NamedEntity | null;
|
||||
@@ -253,8 +254,11 @@ export interface RecipeResultItem extends NamedEntity {
|
||||
|
||||
export interface Item extends EditInfo {
|
||||
id: number;
|
||||
displayId: number;
|
||||
name: string;
|
||||
baseName?: string;
|
||||
details: string;
|
||||
baseDetails?: string;
|
||||
isEventItem: boolean;
|
||||
translations?: TranslationMap;
|
||||
image: EntityImage | null;
|
||||
@@ -270,6 +274,24 @@ export interface Item extends EditInfo {
|
||||
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 {
|
||||
acquisitionMethods: NamedEntity[];
|
||||
recipe: RecipeDetail | null;
|
||||
@@ -296,7 +318,7 @@ export interface DailyChecklistItem {
|
||||
translations?: TranslationMap;
|
||||
}
|
||||
|
||||
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'recipes' | 'checklist';
|
||||
export type DataToolScope = 'pokemon' | 'habitats' | 'items' | 'artifacts' | 'recipes' | 'checklist';
|
||||
|
||||
export interface DataToolScopeSummary {
|
||||
scope: DataToolScope;
|
||||
@@ -395,6 +417,7 @@ export interface Options {
|
||||
favoriteThings: NamedEntity[];
|
||||
itemCategories: NamedEntity[];
|
||||
itemUsages: NamedEntity[];
|
||||
ancientArtifactCategories: NamedEntity[];
|
||||
acquisitionMethods: NamedEntity[];
|
||||
itemTags: NamedEntity[];
|
||||
maps: NamedEntity[];
|
||||
@@ -546,8 +569,6 @@ export type ConfigType =
|
||||
| 'skills'
|
||||
| 'environments'
|
||||
| 'favorite-things'
|
||||
| 'item-categories'
|
||||
| 'item-usages'
|
||||
| 'acquisition-methods'
|
||||
| 'maps'
|
||||
| 'life-tags'
|
||||
@@ -598,7 +619,9 @@ export interface PokemonImageOptionsResult {
|
||||
}
|
||||
|
||||
export interface ItemPayload {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
translations?: TranslationMap;
|
||||
categoryId: number;
|
||||
usageId: number | null;
|
||||
@@ -612,6 +635,16 @@ export interface ItemPayload {
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
export interface AncientArtifactPayload {
|
||||
displayId: number;
|
||||
name: string;
|
||||
details: string;
|
||||
translations?: TranslationMap;
|
||||
categoryId: number;
|
||||
tagIds: number[];
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
export interface RecipePayload {
|
||||
itemId: number;
|
||||
acquisitionMethodIds: number[];
|
||||
@@ -650,7 +683,7 @@ export interface LifeCommentPayload {
|
||||
languageCode?: string | null;
|
||||
}
|
||||
|
||||
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
export type DiscussionEntityType = 'pokemon' | 'items' | 'recipes' | 'habitats' | 'ancient-artifacts';
|
||||
|
||||
export interface EntityDiscussionComment {
|
||||
id: number;
|
||||
@@ -1104,13 +1137,23 @@ export const api = {
|
||||
sendJson<HabitatDetail>(`/api/habitats/${id}`, 'PUT', payload),
|
||||
deleteHabitat: (id: string | number) => deleteJson(`/api/habitats/${id}`),
|
||||
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)}`),
|
||||
itemDetail: (id: string | number) => getJson<ItemDetail>(`/api/items/${id}`),
|
||||
createItem: (payload: ItemPayload) => sendJson<ItemDetail>('/api/items', 'POST', payload),
|
||||
updateItem: (id: string | number, payload: ItemPayload) => sendJson<ItemDetail>(`/api/items/${id}`, 'PUT', payload),
|
||||
deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`),
|
||||
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> = {}) =>
|
||||
getJson<Recipe[]>(`/api/recipes${buildQuery(params)}`),
|
||||
recipeDetail: (id: string | number) => getJson<RecipeDetail>(`/api/recipes/${id}`),
|
||||
|
||||
@@ -4114,6 +4114,13 @@ button:disabled,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.preserve-lines {
|
||||
margin: 0;
|
||||
max-width: 72ch;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.entity-profile-facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
|
||||
|
||||
@@ -12,6 +12,7 @@ import TranslationFields from '../components/TranslationFields.vue';
|
||||
import {
|
||||
iconAdd,
|
||||
iconAdmin,
|
||||
iconArtifact,
|
||||
iconCancel,
|
||||
iconChecklist,
|
||||
iconDelete,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||
import {
|
||||
api,
|
||||
type AncientArtifact,
|
||||
type AiModerationApiFormat,
|
||||
type AiModerationAuthMode,
|
||||
type AiModerationSettings,
|
||||
@@ -76,6 +78,7 @@ type AdminTab =
|
||||
| 'checklist'
|
||||
| 'pokemon'
|
||||
| 'items'
|
||||
| 'ancientArtifacts'
|
||||
| 'recipes'
|
||||
| 'habitats';
|
||||
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
|
||||
@@ -102,7 +105,7 @@ const rateLimitPolicyKeys: RateLimitPolicyKey[] = [
|
||||
'upload',
|
||||
'fetch'
|
||||
];
|
||||
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'recipes', 'checklist'];
|
||||
const dataToolScopeKeys: DataToolScope[] = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'];
|
||||
const defaultRateLimitPolicies: Record<RateLimitPolicyKey, RateLimitPolicySettings> = {
|
||||
accountWrite: { maxRequests: 20, timeWindowSeconds: 60 * 60, cooldownSeconds: 5 },
|
||||
adminWrite: { maxRequests: 120, timeWindowSeconds: 60 * 60, cooldownSeconds: 2 },
|
||||
@@ -126,6 +129,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
checklist: iconChecklist,
|
||||
pokemon: iconPokemon,
|
||||
items: iconItem,
|
||||
ancientArtifacts: iconArtifact,
|
||||
recipes: iconRecipe,
|
||||
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: 'pokemon', label: t('pages.admin.pokemonList'), permission: ['pokemon.order', 'pokemon.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: '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'] }
|
||||
@@ -185,8 +194,6 @@ const configTypes = computed<
|
||||
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
|
||||
{ key: 'environments', label: t('config.environments') },
|
||||
{ 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: 'maps', label: t('config.maps') },
|
||||
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
|
||||
@@ -203,6 +210,7 @@ const languageRows = ref<Language[]>([]);
|
||||
const checklistRows = ref<DailyChecklistItem[]>([]);
|
||||
const pokemonRows = ref<Pokemon[]>([]);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const ancientArtifactRows = ref<AncientArtifact[]>([]);
|
||||
const recipeRows = ref<Recipe[]>([]);
|
||||
const habitatRows = ref<Habitat[]>([]);
|
||||
const wordingRows = ref<SystemWording[]>([]);
|
||||
@@ -401,7 +409,9 @@ const configLabel = (item: EditableConfig) => item.name;
|
||||
const pokemonKey = (item: Pokemon) => item.id;
|
||||
const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
|
||||
const itemKey = (item: Item) => item.id;
|
||||
const itemLabel = (item: Item) => item.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 recipeLabel = (item: Recipe) => item.name;
|
||||
const habitatKey = (item: Habitat) => item.id;
|
||||
@@ -768,6 +778,10 @@ function previewItemOrder(rows: Item[]) {
|
||||
itemRows.value = rows;
|
||||
}
|
||||
|
||||
function previewAncientArtifactOrder(rows: AncientArtifact[]) {
|
||||
ancientArtifactRows.value = rows;
|
||||
}
|
||||
|
||||
function previewRecipeOrder(rows: Recipe[]) {
|
||||
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[]) {
|
||||
recipeRows.value = nextRows;
|
||||
await run(async () => {
|
||||
@@ -944,6 +970,10 @@ async function loadItems() {
|
||||
itemRows.value = await api.items({});
|
||||
}
|
||||
|
||||
async function loadAncientArtifacts() {
|
||||
ancientArtifactRows.value = await api.ancientArtifacts();
|
||||
}
|
||||
|
||||
async function loadRecipes() {
|
||||
recipeRows.value = await api.recipes();
|
||||
}
|
||||
@@ -1121,6 +1151,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
||||
if (activeTab.value === 'checklist') await loadChecklist();
|
||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||
if (activeTab.value === 'items') await loadItems();
|
||||
if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts();
|
||||
if (activeTab.value === 'recipes') await loadRecipes();
|
||||
if (activeTab.value === 'habitats') await loadHabitats();
|
||||
} 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) {
|
||||
await run(async () => {
|
||||
await api.deleteRecipe(id);
|
||||
@@ -1982,7 +2020,7 @@ onMounted(() => {
|
||||
@reorder="persistItemOrder"
|
||||
>
|
||||
<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">
|
||||
<button v-if="can('items.delete')" type="button" :disabled="busy" @click="removeItem(item.id)">
|
||||
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
|
||||
@@ -1994,6 +2032,34 @@ onMounted(() => {
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</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">
|
||||
<h2>{{ t('pages.admin.recipeList') }}</h2>
|
||||
<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 {
|
||||
iconAction,
|
||||
iconArtifact,
|
||||
iconAutomation,
|
||||
iconChevronRight,
|
||||
iconChecklist,
|
||||
@@ -36,6 +37,8 @@ const primarySections = computed(() => [
|
||||
{ key: 'habitats', to: '/habitats', icon: iconHabitat },
|
||||
{ key: 'eventHabitats', to: '/event-habitats', icon: iconEvent },
|
||||
{ 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 }
|
||||
]);
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ const itemSubtitle = computed(() => {
|
||||
|
||||
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(() => {
|
||||
if (!item.value) {
|
||||
@@ -55,7 +57,7 @@ async function loadItemDetail() {
|
||||
|
||||
if (route.meta.editorModal !== true) {
|
||||
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 }),
|
||||
canonicalPath: `/items/${nextItem.id}`,
|
||||
image: nextItem.image?.url
|
||||
@@ -147,14 +149,14 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="page-stack">
|
||||
<PageHeader :title="item.name" :subtitle="itemSubtitle">
|
||||
<template #kicker>{{ t('pages.items.detailKicker') }}</template>
|
||||
<PageHeader :title="`#${item.displayId} ${item.name}`" :subtitle="itemSubtitle">
|
||||
<template #kicker>{{ detailKicker }}</template>
|
||||
<template #actions>
|
||||
<RouterLink v-if="canUpdateItem" class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</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" />
|
||||
{{ t('common.backToList') }}
|
||||
</RouterLink>
|
||||
@@ -180,6 +182,10 @@ watch(
|
||||
<div class="entity-profile-main">
|
||||
<section class="detail-section entity-profile-overview" :aria-label="t('common.details')">
|
||||
<dl class="entity-profile-facts">
|
||||
<div>
|
||||
<dt>{{ t('pages.items.displayId') }}</dt>
|
||||
<dd>#{{ item.displayId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('pages.items.category') }}</dt>
|
||||
<dd>{{ item.category.name }}</dd>
|
||||
@@ -195,6 +201,11 @@ watch(
|
||||
</dl>
|
||||
|
||||
<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">
|
||||
<h3 class="section-subtitle">{{ t('pages.items.acquisitionMethods') }}</h3>
|
||||
<EntityChips v-if="item.acquisitionMethods.length" :items="item.acquisitionMethods" />
|
||||
|
||||
@@ -36,7 +36,9 @@ const busy = ref(false);
|
||||
const message = ref('');
|
||||
const creatingSelect = ref('');
|
||||
const itemForm = ref({
|
||||
displayId: 1,
|
||||
name: '',
|
||||
details: '',
|
||||
translations: {} as TranslationMap,
|
||||
categoryId: '',
|
||||
usageId: '',
|
||||
@@ -52,12 +54,15 @@ const itemForm = ref({
|
||||
|
||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||
const isEditing = computed(() => routeId.value !== '');
|
||||
const isEventCreate = computed(() => route.name === 'event-item-new');
|
||||
const pageTitle = computed(() =>
|
||||
isEditing.value
|
||||
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
|
||||
: t('pages.items.newTitle')
|
||||
: isEventCreate.value
|
||||
? t('pages.eventItems.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 imageEntityName = computed(() => itemNameForSave().trim());
|
||||
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||
@@ -112,7 +117,9 @@ async function loadEditor() {
|
||||
if (isEditing.value) {
|
||||
const item = await api.itemDetail(routeId.value);
|
||||
itemForm.value = {
|
||||
displayId: item.displayId,
|
||||
name: item.baseName ?? item.name,
|
||||
details: item.baseDetails ?? item.details,
|
||||
translations: item.translations ?? {},
|
||||
categoryId: String(item.category.id),
|
||||
usageId: item.usage ? String(item.usage.id) : '',
|
||||
@@ -128,6 +135,10 @@ async function loadEditor() {
|
||||
currentImage.value = item.image;
|
||||
imageHistory.value = item.imageHistory;
|
||||
hasRecipe.value = item.recipe !== null;
|
||||
} else if (isEventCreate.value) {
|
||||
itemForm.value.isEventItem = true;
|
||||
} else {
|
||||
itemForm.value.isEventItem = false;
|
||||
}
|
||||
} catch (error) {
|
||||
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[]) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName || !canCreateConfig.value) return;
|
||||
@@ -179,7 +173,9 @@ async function saveItem() {
|
||||
|
||||
try {
|
||||
const payload: ItemPayload = {
|
||||
displayId: itemForm.value.displayId,
|
||||
name: itemNameForSave(),
|
||||
details: itemForm.value.details,
|
||||
translations: itemForm.value.translations,
|
||||
categoryId: Number(itemForm.value.categoryId),
|
||||
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
|
||||
@@ -230,6 +226,22 @@ onMounted(() => {
|
||||
required
|
||||
/>
|
||||
|
||||
<div class="field">
|
||||
<label for="item-display-id">{{ t('pages.items.displayId') }}</label>
|
||||
<input id="item-display-id" v-model.number="itemForm.displayId" type="number" min="1" required />
|
||||
</div>
|
||||
|
||||
<TranslationFields
|
||||
id-prefix="item-details"
|
||||
v-model:base-value="itemForm.details"
|
||||
v-model:translations="itemForm.translations"
|
||||
field="details"
|
||||
:label="t('pages.items.description')"
|
||||
:languages="languages"
|
||||
multiline
|
||||
:rows="4"
|
||||
/>
|
||||
|
||||
<ImageUploadField
|
||||
v-model="itemForm.imagePath"
|
||||
entity-type="items"
|
||||
@@ -252,11 +264,8 @@ onMounted(() => {
|
||||
v-model="itemForm.categoryId"
|
||||
:options="options.itemCategories"
|
||||
:multiple="false"
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'item-category'"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.items.searchCategory')"
|
||||
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -267,11 +276,8 @@ onMounted(() => {
|
||||
v-model="itemForm.usageId"
|
||||
:options="options.itemUsages"
|
||||
:multiple="false"
|
||||
:allow-create="canCreateConfig"
|
||||
:creating="creatingSelect === 'item-usage'"
|
||||
:placeholder="t('common.none')"
|
||||
:search-placeholder="t('pages.items.searchUsage')"
|
||||
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -280,7 +286,7 @@ onMounted(() => {
|
||||
<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.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 class="field">
|
||||
|
||||
@@ -13,6 +13,10 @@ import { iconAdd, iconItem } from '../icons';
|
||||
import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api';
|
||||
import ItemEdit from './ItemEdit.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
eventOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const options = ref<Options | null>(null);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
@@ -27,6 +31,10 @@ const tagIds = ref<string[]>([]);
|
||||
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
|
||||
const filterSkeletonWidths = ['52px', '48px', '48px'];
|
||||
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[]>(() => [
|
||||
{ value: '', label: t('common.all') },
|
||||
@@ -37,9 +45,10 @@ const itemQuery = computed(() => ({
|
||||
search: search.value,
|
||||
categoryId: categoryId.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);
|
||||
|
||||
function itemCardImage(item: Item) {
|
||||
@@ -69,10 +78,10 @@ watch(itemQuery, loadItems);
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
|
||||
<template #kicker>Bag</template>
|
||||
<PageHeader :title="pageTitle" :subtitle="pageSubtitle">
|
||||
<template #kicker>{{ pageKicker }}</template>
|
||||
<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" />
|
||||
{{ t('common.add') }}
|
||||
</RouterLink>
|
||||
@@ -136,7 +145,7 @@ watch(itemQuery, loadItems);
|
||||
<EntityCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:title="`#${item.displayId} ${item.name}`"
|
||||
:subtitle="item.category.name"
|
||||
:to="`/items/${item.id}`"
|
||||
:icon="iconItem"
|
||||
|
||||
@@ -114,7 +114,7 @@ watch(
|
||||
</div>
|
||||
</section>
|
||||
<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 #actions>
|
||||
<RouterLink v-if="canUpdateRecipe" class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">
|
||||
@@ -145,7 +145,7 @@ watch(
|
||||
<Icon :icon="iconRecipe" class="entity-card__icon" aria-hidden="true" />
|
||||
</span>
|
||||
</RouterLink>
|
||||
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">{{ recipe.item.name }}</RouterLink>
|
||||
<RouterLink class="entity-profile-title-link" :to="`/items/${recipe.item.id}`">#{{ recipe.item.displayId }} {{ recipe.item.name }}</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ watch(itemQuery, loadItems);
|
||||
<EntityCard
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:title="item.name"
|
||||
:title="`#${item.displayId} ${item.name}`"
|
||||
:subtitle="item.category.name"
|
||||
:to="recipeTarget(item)"
|
||||
:icon="itemIcon(item)"
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
} from '../services/api';
|
||||
|
||||
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 ReactionFilter = 'all' | LifeReactionType;
|
||||
type CommentFilter = 'all' | ProfileCommentSource;
|
||||
@@ -46,6 +46,7 @@ type CommentFilter = 'all' | ProfileCommentSource;
|
||||
const primaryContributionFilters: PrimaryContributionFilter[] = [
|
||||
'pokemon',
|
||||
'items',
|
||||
'ancient-artifacts',
|
||||
'recipes',
|
||||
'habitats',
|
||||
'daily-checklist'
|
||||
@@ -582,6 +583,7 @@ function contentTypeLabel(contentType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
pokemon: t('nav.pokemon'),
|
||||
items: t('nav.items'),
|
||||
'ancient-artifacts': t('nav.ancientArtifacts'),
|
||||
recipes: t('nav.recipes'),
|
||||
habitats: t('nav.habitats'),
|
||||
'daily-checklist': t('nav.checklist'),
|
||||
@@ -589,8 +591,6 @@ function contentTypeLabel(contentType: string): string {
|
||||
skills: t('config.skills'),
|
||||
environments: t('config.environments'),
|
||||
'favorite-things': t('config.favoriteThings'),
|
||||
'item-categories': t('config.itemCategories'),
|
||||
'item-usages': t('config.itemUsages'),
|
||||
'acquisition-methods': t('config.acquisitionMethods'),
|
||||
maps: t('config.maps'),
|
||||
'life-tags': t('config.lifeCategories')
|
||||
@@ -603,7 +603,8 @@ function discussionTargetRoute(type: DiscussionEntityType, id: number): string {
|
||||
pokemon: `/pokemon/${id}`,
|
||||
items: `/items/${id}`,
|
||||
recipes: `/recipes/${id}`,
|
||||
habitats: `/habitats/${id}`
|
||||
habitats: `/habitats/${id}`,
|
||||
'ancient-artifacts': `/ancient-artifacts/${id}`
|
||||
}[type];
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,18 @@ import vue from '@vitejs/plugin-vue';
|
||||
|
||||
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
|
||||
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 = [
|
||||
'/admin',
|
||||
'/login',
|
||||
@@ -18,7 +29,10 @@ const robotsDisallowPaths = [
|
||||
'/event-habitats/new',
|
||||
'/habitats/*/edit',
|
||||
'/items/new',
|
||||
'/event-items/new',
|
||||
'/items/*/edit',
|
||||
'/ancient-artifacts/new',
|
||||
'/ancient-artifacts/*/edit',
|
||||
'/recipes/new',
|
||||
'/recipes/*/edit',
|
||||
'/automation',
|
||||
|
||||
@@ -48,6 +48,8 @@ export const systemWordingMessages = {
|
||||
habitats: 'Habitats',
|
||||
eventHabitats: 'Event Habitats',
|
||||
items: 'Items',
|
||||
eventItems: 'Event Items',
|
||||
ancientArtifacts: 'Ancient Artifacts',
|
||||
recipes: 'Recipes',
|
||||
automation: 'Automation',
|
||||
dish: 'Dish',
|
||||
@@ -80,11 +82,13 @@ export const systemWordingMessages = {
|
||||
},
|
||||
seo: {
|
||||
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:
|
||||
'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.',
|
||||
itemDetailDescription:
|
||||
'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:
|
||||
'View {name} habitat details in Pokopia Wiki, including recipes, possible Pokemon, maps, time, weather, discussions, and edit history.',
|
||||
recipeDetailDescription:
|
||||
@@ -191,6 +195,14 @@ export const systemWordingMessages = {
|
||||
title: 'Items',
|
||||
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: {
|
||||
title: 'Recipes',
|
||||
description: 'Find result items, materials, and acquisition details.'
|
||||
@@ -608,6 +620,7 @@ export const systemWordingMessages = {
|
||||
items: {
|
||||
title: 'Items',
|
||||
subtitle: 'Browse items by category, usage, and tags.',
|
||||
kicker: 'Items',
|
||||
detailKicker: 'Item Detail',
|
||||
detailSubtitle: 'Item detail',
|
||||
editKicker: 'Item Edit',
|
||||
@@ -618,6 +631,8 @@ export const systemWordingMessages = {
|
||||
loadingList: 'Loading item list',
|
||||
loadingDetail: 'Loading item detail',
|
||||
loadingEdit: 'Loading item editor',
|
||||
displayId: 'Display ID',
|
||||
description: 'Description',
|
||||
category: 'Category',
|
||||
usage: 'Usage',
|
||||
tags: 'Tags',
|
||||
@@ -638,6 +653,35 @@ export const systemWordingMessages = {
|
||||
searchMethods: 'Search acquisition methods',
|
||||
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: {
|
||||
title: 'Recipes',
|
||||
subtitle: 'Browse recipes by category, usage, and tags.',
|
||||
@@ -862,6 +906,7 @@ export const systemWordingMessages = {
|
||||
checklist: 'CheckList',
|
||||
pokemonList: 'Pokemon list',
|
||||
itemList: 'Item list',
|
||||
ancientArtifactList: 'Ancient Artifact list',
|
||||
recipeList: 'Recipe list',
|
||||
habitatList: 'Habitat list',
|
||||
dataTools: 'Data tools',
|
||||
@@ -886,6 +931,7 @@ export const systemWordingMessages = {
|
||||
dataToolScopePokemon: 'Pokemon',
|
||||
dataToolScopeHabitats: 'Habitats',
|
||||
dataToolScopeItems: 'Items',
|
||||
dataToolScopeArtifacts: 'Ancient Artifacts',
|
||||
dataToolScopeRecipes: 'Recipes',
|
||||
dataToolScopeChecklist: 'Daily CheckList',
|
||||
languages: 'Languages',
|
||||
@@ -973,8 +1019,6 @@ export const systemWordingMessages = {
|
||||
skills: 'Specialities',
|
||||
environments: 'Ideal Habitats',
|
||||
favoriteThings: 'Favourites / tags',
|
||||
itemCategories: 'Item categories',
|
||||
itemUsages: 'Item usages',
|
||||
acquisitionMethods: 'Acquisition methods',
|
||||
maps: 'Maps',
|
||||
lifeCategories: 'Life categories',
|
||||
@@ -1151,9 +1195,12 @@ export const systemWordingMessages = {
|
||||
environmentRequired: 'Ideal Habitat is required',
|
||||
skillNoDrop: 'This speciality cannot have a drop item',
|
||||
habitatNameRequired: 'Habitat name is required',
|
||||
itemDisplayIdRequired: 'Item Display ID is required',
|
||||
usageRequired: 'Usage is required',
|
||||
itemNameRequired: 'Item name is required',
|
||||
categoryRequired: 'Category is required',
|
||||
artifactDisplayIdRequired: 'Ancient Artifact Display ID is required',
|
||||
artifactNameRequired: 'Ancient Artifact name is required',
|
||||
recipeFreeWithRecipe: 'An item with a recipe cannot be marked as recipe-free',
|
||||
itemRequired: 'Item is required',
|
||||
recipeFreeItem: 'This item is marked as recipe-free',
|
||||
@@ -1246,6 +1293,8 @@ export const systemWordingMessages = {
|
||||
habitats: '栖息地',
|
||||
eventHabitats: 'Event Habitats',
|
||||
items: '物品',
|
||||
eventItems: 'Event Items',
|
||||
ancientArtifacts: 'Ancient Artifacts',
|
||||
recipes: '材料单',
|
||||
automation: '自动化',
|
||||
dish: '料理',
|
||||
@@ -1277,9 +1326,10 @@ export const systemWordingMessages = {
|
||||
}
|
||||
},
|
||||
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 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。',
|
||||
itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。',
|
||||
ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。',
|
||||
habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。',
|
||||
recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。'
|
||||
},
|
||||
@@ -1384,6 +1434,14 @@ export const systemWordingMessages = {
|
||||
title: '物品',
|
||||
description: '按分类、用途、入手方式、自定义和标签浏览物品。'
|
||||
},
|
||||
eventItems: {
|
||||
title: 'Event Items',
|
||||
description: '浏览限时活动物品,并维护独立的 Display ID 与共享分类。'
|
||||
},
|
||||
ancientArtifacts: {
|
||||
title: 'Ancient Artifacts',
|
||||
description: '浏览 Lost Relics 和 Fossils 的标签、介绍与 Wiki 历史。'
|
||||
},
|
||||
recipes: {
|
||||
title: '材料单',
|
||||
description: '查找结果物品、需要材料和入手方式。'
|
||||
@@ -1781,6 +1839,7 @@ export const systemWordingMessages = {
|
||||
items: {
|
||||
title: '物品',
|
||||
subtitle: '按分类、用途、标签查看物品。',
|
||||
kicker: '物品',
|
||||
detailKicker: 'Item Detail',
|
||||
detailSubtitle: '物品详情',
|
||||
editKicker: 'Item Edit',
|
||||
@@ -1791,6 +1850,8 @@ export const systemWordingMessages = {
|
||||
loadingList: '正在加载列表',
|
||||
loadingDetail: '正在加载物品详情',
|
||||
loadingEdit: '正在加载物品编辑内容',
|
||||
displayId: 'Display ID',
|
||||
description: '介绍',
|
||||
category: '分类',
|
||||
usage: '用途',
|
||||
tags: '标签',
|
||||
@@ -1811,6 +1872,35 @@ export const systemWordingMessages = {
|
||||
searchMethods: '搜索入手方式',
|
||||
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: {
|
||||
title: '材料单',
|
||||
subtitle: '按分类、用途、标签查看材料单。',
|
||||
@@ -2035,6 +2125,7 @@ export const systemWordingMessages = {
|
||||
checklist: 'CheckList',
|
||||
pokemonList: 'Pokemon 列表',
|
||||
itemList: '物品列表',
|
||||
ancientArtifactList: 'Ancient Artifact 列表',
|
||||
recipeList: '材料单列表',
|
||||
habitatList: '栖息地列表',
|
||||
dataTools: '数据工具',
|
||||
@@ -2059,6 +2150,7 @@ export const systemWordingMessages = {
|
||||
dataToolScopePokemon: 'Pokemon',
|
||||
dataToolScopeHabitats: '栖息地',
|
||||
dataToolScopeItems: '物品',
|
||||
dataToolScopeArtifacts: 'Ancient Artifacts',
|
||||
dataToolScopeRecipes: '材料单',
|
||||
dataToolScopeChecklist: '每日 CheckList',
|
||||
languages: '语言',
|
||||
@@ -2146,8 +2238,6 @@ export const systemWordingMessages = {
|
||||
skills: '特长',
|
||||
environments: '喜欢的环境',
|
||||
favoriteThings: '喜欢的东西 / 标签',
|
||||
itemCategories: '物品分类',
|
||||
itemUsages: '物品用途',
|
||||
acquisitionMethods: '入手方式',
|
||||
maps: '地图',
|
||||
lifeCategories: 'Life Categories',
|
||||
@@ -2324,9 +2414,12 @@ export const systemWordingMessages = {
|
||||
environmentRequired: '请选择喜欢的环境',
|
||||
skillNoDrop: '这个特长不能设置掉落物',
|
||||
habitatNameRequired: '请输入栖息地名称',
|
||||
itemDisplayIdRequired: '请输入物品 Display ID',
|
||||
usageRequired: '请选择用途',
|
||||
itemNameRequired: '请输入物品名称',
|
||||
categoryRequired: '请选择分类',
|
||||
artifactDisplayIdRequired: '请输入 Ancient Artifact Display ID',
|
||||
artifactNameRequired: '请输入 Ancient Artifact 名称',
|
||||
recipeFreeWithRecipe: '已有材料单的物品不能标记为无材料单',
|
||||
itemRequired: '请选择物品',
|
||||
recipeFreeItem: '这个物品已标记为无材料单',
|
||||
|
||||
Reference in New Issue
Block a user