From 4238be7761dc9dfc01b504c81db9db8e427cc378 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Mon, 4 May 2026 08:28:56 +0800 Subject: [PATCH] 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 --- DESIGN.md | 109 +++- backend/db/schema.sql | 242 +++++++- backend/src/queries.ts | 581 ++++++++++++++++--- backend/src/server.ts | 66 ++- backend/src/uploads.ts | 4 +- frontend/index.html | 6 +- frontend/src/App.vue | 3 + frontend/src/components/EditHistoryPanel.vue | 2 + frontend/src/icons.ts | 1 + frontend/src/router/index.ts | 71 ++- frontend/src/services/api.ts | 55 +- frontend/src/styles/main.css | 7 + frontend/src/views/AdminView.vue | 76 ++- frontend/src/views/AncientArtifactDetail.vue | 170 ++++++ frontend/src/views/AncientArtifactEdit.vue | 269 +++++++++ frontend/src/views/AncientArtifactList.vue | 139 +++++ frontend/src/views/HomeView.vue | 3 + frontend/src/views/ItemDetail.vue | 19 +- frontend/src/views/ItemEdit.vue | 58 +- frontend/src/views/ItemsList.vue | 21 +- frontend/src/views/RecipeDetail.vue | 4 +- frontend/src/views/RecipeList.vue | 2 +- frontend/src/views/UserProfileView.vue | 9 +- frontend/vite.config.ts | 16 +- system-wordings.ts | 105 +++- 25 files changed, 1857 insertions(+), 181 deletions(-) create mode 100644 frontend/src/views/AncientArtifactDetail.vue create mode 100644 frontend/src/views/AncientArtifactEdit.vue create mode 100644 frontend/src/views/AncientArtifactList.vue diff --git a/DESIGN.md b/DESIGN.md index ebfbaa8..1e5a513 100644 --- a/DESIGN.md +++ b/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: diff --git a/backend/db/schema.sql b/backend/db/schema.sql index c9a38e9..f61b76b 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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; diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 464372c..21c1770 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -22,7 +22,7 @@ type QueryValue = string | string[] | undefined; type QueryParams = Record; 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; +}; + +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 = { '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 = { const sortableContentDefinitions: Record = { 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 | 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> { 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 { 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 { + 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, userId: num return listItems({}, locale); } +export async function reorderAncientArtifacts(payload: Record, userId: number, locale = defaultLocale) { + await reorderContent('ancient-artifacts', payload, userId); + return listAncientArtifacts({}, locale); +} + export async function reorderRecipes(payload: Record, 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): 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, 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, 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, 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, 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, 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): 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 { + 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, 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, 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 = { 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): 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 { 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 } async function resetDataToolIdentities(client: DbClient): Promise { - 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 { await client.query('DELETE FROM items'); } +async function wipeAncientArtifactsData(client: DbClient): Promise { + 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 { 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 { 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 { 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')); diff --git a/backend/src/server.ts b/backend/src/server.ts index 5647545..b281346 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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, 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, 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, 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, requestLocale(request)) ); @@ -1739,6 +1798,11 @@ app.put('/api/admin/items/order', async (request, reply) => { return user ? reorderItems(request.body as Record, 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, 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, user.id, requestLocale(request)) : undefined; diff --git a/backend/src/uploads.ts b/backend/src/uploads.ts index 4a826ae..2aa1aa8 100644 --- a/backend/src/uploads.ts +++ b/backend/src/uploads.ts @@ -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(['pokemon', 'items', 'habitats']); +const uploadEntityTypes = new Set(['pokemon', 'items', 'habitats', 'ancient-artifacts']); const imageMimeTypes = new Map([ ['image/png', '.png'], ['image/jpeg', '.jpg'], diff --git a/frontend/index.html b/frontend/index.html index ba570b2..5aa7eb2 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ @@ -16,7 +16,7 @@ @@ -25,7 +25,7 @@ + + diff --git a/frontend/src/views/AncientArtifactEdit.vue b/frontend/src/views/AncientArtifactEdit.vue new file mode 100644 index 0000000..99814e2 --- /dev/null +++ b/frontend/src/views/AncientArtifactEdit.vue @@ -0,0 +1,269 @@ + + + diff --git a/frontend/src/views/AncientArtifactList.vue b/frontend/src/views/AncientArtifactList.vue new file mode 100644 index 0000000..3ff7afc --- /dev/null +++ b/frontend/src/views/AncientArtifactList.vue @@ -0,0 +1,139 @@ + + + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 0bc1709..9eaeff3 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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 } ]); diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 7046dd5..21e4b17 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -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(
- - + +