diff --git a/DESIGN.md b/DESIGN.md index d835d33..6e66440 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -58,8 +58,7 @@ - 喜欢的环境 - 喜欢的东西 / 标签 - 入手方式 - - 物品 - - Ancient Artifacts + - 物品(包含 Ancient Artifacts 视图中的物品) - 地图 - 栖息地 - 每日 CheckList Task @@ -71,7 +70,7 @@ - 支持翻译的字段: - `name` - `title` - - `details`:Pokemon、物品和 Ancient Artifacts 的介绍 / 说明 + - `details`:Pokemon 和物品的介绍 / 说明 - `genus`:仅 Pokemon Genus 使用 - `effect`:Dish Category 的吃后效果 - `mosslaxEffect`:Dish 给 Mosslax 吃之后的效果 @@ -218,7 +217,7 @@ - Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。 - Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。 - Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。 - - Wipe Ancient Artifacts 会删除 Ancient Artifacts、标签关联、实体翻译、编辑历史和实体讨论评论。 + - Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。 - Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。 - Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。 - 对被清空的 identity 主表重置自增 ID;Pokemon 内部 ID 不是 identity,未关联官方 data 的自定义 Pokemon 系统分配区间仍按当前数据库最大值继续。 @@ -597,6 +596,10 @@ Pokemon 详情页展示: - 名称 - 介绍 - Base Price:可为空 +- Ancient Artifact:可为空,Items Edit 使用单选框维护;`No` 表示普通物品,其他值使用系统固定列表: + - Lost Relics (L) + - Lost Relics (S) + - Fossils - 是否为 Event Item:`is_event_item` - 分类:必填,使用系统固定列表,不在管理端配置: - Furniture @@ -632,6 +635,7 @@ Items 与 Event Items 使用相同数据模型: - Items 列表只展示 `is_event_item = false` 的物品。 - Event Items 列表只展示 `is_event_item = true` 的物品。 - Event Items 与 Items 共用物品分类、用途、入手方式、标签、图片、翻译和材料单逻辑。 +- 已选择 Ancient Artifact 分类的物品仍显示在 Items / Event Items 列表中,并额外进入 Ancient Artifacts 对应分类列表。 物品列表功能: @@ -654,6 +658,7 @@ Items 与 Event Items 使用相同数据模型: - 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图 - 介绍 - Base Price +- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示 - 分类 - 用途 - 入手方式 @@ -669,12 +674,12 @@ Items 与 Event Items 使用相同数据模型: ## Ancient Artifacts -Ancient Artifacts 是独立 Wiki 内容类型,可配置: +Ancient Artifacts 是 Items 的可选分类视图,不再维护独立主数据结构或独立表;列表、详情和排序从 `items.ancient_artifact_category_key IS NOT NULL` 的物品获取。已配置 Ancient Artifact 分类的物品仍保留在 Items / Event Items 列表中,并额外出现在 Ancient Artifacts 对应分类列表。Ancient Artifact 路由继续保留,用于浏览、编辑和导航对应的物品记录。 - 名称 - 介绍 -- 图片:使用 Ancient Artifacts 上传目录,支持图片历史 -- 分类:必填,使用系统固定列表,不在管理端配置: +- 图片:使用 Items 编辑器和上传目录,支持图片历史 +- 分类:在 Items Edit 的 Ancient Artifact 单选框中维护;`No` 表示不进入 Ancient Artifacts 列表,其他选项使用系统固定列表,不在管理端配置: - Lost Relics (L) - Lost Relics (S) - Fossils @@ -692,16 +697,7 @@ Ancient Artifacts 列表功能: - 列表移动端保持常规卡片布局,展示图片 / 默认 Ancient Artifact 标记、名称和分类。 - 列表不展示编辑元信息。 -Ancient Artifacts 详情页展示: - -- 名称 -- 图片;未配置图片时展示默认 Ancient Artifact 标记 -- 介绍 -- 分类 -- 标签 -- 最后编辑信息 -- 讨论 -- 编辑历史 +Ancient Artifacts 详情页使用同一套 Item Details 视图展示同一条 `items` 记录;顶部、图片、基础信息、Base Price、物品分类、用途、入手方式、客制化、标签、材料单关联、讨论和编辑历史均按物品详情页规则展示,并额外展示 Ancient Artifact 分类。通过 `/ancient-artifacts/:id` 打开的普通非 Ancient Artifact 物品会回到对应 `/items/:id`。 ## 材料单 @@ -1000,6 +996,7 @@ API 暴露边界: - `/ancient-artifacts/:id/edit` - `/recipes/new` - `/recipes/:id/edit` +- `/ancient-artifacts/new` 和 `/ancient-artifacts/:id/edit` 使用 Items 编辑器与 Items create/update 权限;保存的是同一条 `items` 记录。 - Life 使用信息流顶部 New Post / 编辑按钮打开普通 Modal 发布与编辑,不使用路由驱动 Modal。 - 进入或关闭编辑 Modal 时应保留底层页面上下文,不进行不必要的滚动跳转。 - 用户界面不得展示内部字段名、调试数据、计划说明或“已修改某字段”一类实现说明。 @@ -1053,7 +1050,7 @@ API 暴露边界: - `GET /api/pokemon/:id` - `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;未传时返回全部栖息地以兼容管理端和实体选择器 - `GET /api/habitats/:id` -- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;未传时返回全部 Items 以兼容管理端和实体选择器 +- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品 - `GET /api/items/:id` - `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选 - `GET /api/ancient-artifacts/:id` diff --git a/backend/db/schema.sql b/backend/db/schema.sql index fc74e6b..82d6b3c 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -977,6 +977,7 @@ CREATE TABLE IF NOT EXISTS items ( name text NOT NULL UNIQUE, details text NOT NULL DEFAULT '', base_price integer, + ancient_artifact_category_key text, category_key text NOT NULL DEFAULT 'other', usage_key text, category_id integer REFERENCES item_categories(id), @@ -1006,22 +1007,13 @@ CREATE TABLE IF NOT EXISTS items ( 'key-items', 'other' )), + CHECK ( + ancient_artifact_category_key IS NULL + OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils') + ), 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, - 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() -); - CREATE TABLE IF NOT EXISTS recipes ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, item_id integer NOT NULL UNIQUE REFERENCES items(id), @@ -1050,12 +1042,6 @@ 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, @@ -1222,6 +1208,7 @@ ALTER TABLE life_tags ALTER TABLE items ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS base_price integer, + ADD COLUMN IF NOT EXISTS ancient_artifact_category_key text, ADD COLUMN IF NOT EXISTS category_key text, ADD COLUMN IF NOT EXISTS usage_key text; @@ -1229,9 +1216,6 @@ UPDATE items SET base_price = NULL WHERE base_price < 0; -ALTER TABLE ancient_artifacts - ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; - DO $$ BEGIN IF EXISTS ( @@ -1308,11 +1292,16 @@ ALTER TABLE items ALTER TABLE items DROP CONSTRAINT IF EXISTS items_display_id_positive, DROP CONSTRAINT IF EXISTS items_base_price_check, + DROP CONSTRAINT IF EXISTS items_ancient_artifact_category_key_check, DROP CONSTRAINT IF EXISTS items_category_key_check, DROP CONSTRAINT IF EXISTS items_usage_key_check; ALTER TABLE items ADD CONSTRAINT items_base_price_check CHECK (base_price IS NULL OR base_price >= 0), + ADD CONSTRAINT items_ancient_artifact_category_key_check CHECK ( + ancient_artifact_category_key IS NULL + OR ancient_artifact_category_key IN ('lost-relics-l', 'lost-relics-s', 'fossils') + ), ADD CONSTRAINT items_category_key_check CHECK (category_key IN ( 'furniture', 'misc', @@ -1329,19 +1318,104 @@ ALTER TABLE items )), ADD CONSTRAINT items_usage_key_check CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road')); +DO $$ +BEGIN + IF to_regclass('ancient_artifacts') IS NOT NULL THEN + ALTER TABLE ancient_artifacts + ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; + + CREATE TEMP TABLE migrated_ancient_artifact_items ( + old_id integer PRIMARY KEY, + item_id integer NOT NULL + ) ON COMMIT DROP; + + INSERT INTO items ( + name, + details, + ancient_artifact_category_key, + category_key, + image_path, + sort_order, + created_by_user_id, + updated_by_user_id, + created_at, + updated_at + ) + SELECT + a.name, + a.details, + a.category_key, + 'other', + a.image_path, + a.sort_order, + a.created_by_user_id, + a.updated_by_user_id, + a.created_at, + a.updated_at + FROM ancient_artifacts a + ON CONFLICT (name) DO UPDATE + SET ancient_artifact_category_key = EXCLUDED.ancient_artifact_category_key, + details = CASE WHEN items.details = '' THEN EXCLUDED.details ELSE items.details END, + image_path = CASE WHEN items.image_path = '' THEN EXCLUDED.image_path ELSE items.image_path END, + updated_by_user_id = COALESCE(items.updated_by_user_id, EXCLUDED.updated_by_user_id), + updated_at = GREATEST(items.updated_at, EXCLUDED.updated_at); + + INSERT INTO migrated_ancient_artifact_items (old_id, item_id) + SELECT a.id, i.id + FROM ancient_artifacts a + JOIN items i ON i.name = a.name + ON CONFLICT (old_id) DO UPDATE SET item_id = EXCLUDED.item_id; + + IF to_regclass('ancient_artifact_favorite_things') IS NOT NULL THEN + INSERT INTO item_favorite_things (item_id, favorite_thing_id) + SELECT m.item_id, aft.favorite_thing_id + FROM ancient_artifact_favorite_things aft + JOIN migrated_ancient_artifact_items m ON m.old_id = aft.ancient_artifact_id + ON CONFLICT DO NOTHING; + END IF; + + INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value) + SELECT 'items', m.item_id, et.locale, et.field_name, et.value + FROM entity_translations et + JOIN migrated_ancient_artifact_items m ON m.old_id = et.entity_id + WHERE et.entity_type = 'ancient-artifacts' + ON CONFLICT (entity_type, entity_id, locale, field_name) DO UPDATE + SET value = EXCLUDED.value; + + UPDATE wiki_edit_logs wel + SET entity_type = 'items', + entity_id = m.item_id + FROM migrated_ancient_artifact_items m + WHERE wel.entity_type = 'ancient-artifacts' + AND wel.entity_id = m.old_id; + + UPDATE entity_image_uploads eiu + SET entity_type = 'items', + entity_id = m.item_id + FROM migrated_ancient_artifact_items m + WHERE eiu.entity_type = 'ancient-artifacts' + AND eiu.entity_id = m.old_id; + + UPDATE entity_discussion_comments edc + SET entity_id = m.item_id + FROM migrated_ancient_artifact_items m + WHERE edc.entity_type = 'ancient-artifacts' + AND edc.entity_id = m.old_id; + + DELETE FROM entity_translations + WHERE entity_type = 'ancient-artifacts'; + END IF; +END $$; + DROP INDEX IF EXISTS items_display_event_item_key; DROP INDEX IF EXISTS items_display_order_idx; DROP INDEX IF EXISTS ancient_artifacts_display_order_idx; -ALTER TABLE ancient_artifacts - DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_key, - DROP CONSTRAINT IF EXISTS ancient_artifacts_display_id_check; - ALTER TABLE items DROP COLUMN IF EXISTS display_id; -ALTER TABLE ancient_artifacts - DROP COLUMN IF EXISTS display_id; +DROP TABLE IF EXISTS ancient_artifact_favorite_things; +DROP TABLE IF EXISTS ancient_artifacts; 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); @@ -1355,7 +1429,6 @@ 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 INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id); CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id); CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id); CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index e3a224d..5240a88 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -204,6 +204,8 @@ type ItemPayload = { name: string; details: string; basePrice: number | null; + ancientArtifactCategoryId: number | null; + ancientArtifactCategoryKey: string | null; translations: TranslationInput; categoryId: number; categoryKey: string; @@ -545,6 +547,7 @@ type ItemChangeSource = { name: string; details: string; basePrice: number | null; + ancientArtifactCategory: { name: string } | null; isEventItem: boolean; image: EntityImageValue | null; category: { name: string }; @@ -674,7 +677,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' }, + 'ancient-artifacts': { table: 'items', entityType: 'ancient-artifacts' }, recipes: { table: 'recipes', entityType: 'recipes' }, habitats: { table: 'habitats', entityType: 'habitats' } }; @@ -684,7 +687,7 @@ const discussionEntityDefinitions: Record | null = null; @@ -1047,6 +1050,17 @@ function cleanUploadImagePath(value: unknown, entityType: 'items' | 'habitats' | return imagePath; } +function cleanItemOrArtifactImagePath(value: unknown): string { + const imagePath = cleanOptionalText(value); + if (imagePath === '') { + return ''; + } + if (!isUploadImagePath(imagePath) || (!imagePath.startsWith('items/') && !imagePath.startsWith('ancient-artifacts/'))) { + throw validationError('server.validation.imagePathInvalid'); + } + return imagePath; +} + function cleanIds(value: unknown): number[] { if (!Array.isArray(value)) { return []; @@ -1093,6 +1107,19 @@ function cleanOptionalNonNegativeInteger(value: unknown, message: string): numbe return numberValue; } +function cleanOptionalSystemListOption( + value: unknown, + options: readonly SystemListOption[], + message: string +): SystemListOption | null { + const optionId = cleanOptionalNonNegativeInteger(value, message); + if (optionId === null) { + return null; + } + + return systemListOptionById(options, optionId, message); +} + function cleanQuantities(value: unknown): IdQuantity[] { if (!Array.isArray(value)) { return []; @@ -2268,6 +2295,12 @@ async function itemEditChanges( before.basePrice === null ? null : String(before.basePrice), after.basePrice === null ? null : String(after.basePrice) ); + pushChange( + changes, + 'Ancient Artifact', + before.ancientArtifactCategory?.name ?? 'None', + systemListNameByKey(ancientArtifactCategoryOptions, after.ancientArtifactCategoryKey) ?? 'None' + ); 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)); @@ -2534,8 +2567,8 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau const habitatName = localizedName('habitats', 'h', locale); const itemName = localizedName('items', 'i', locale); const itemCategoryName = systemListJsonSql('i.category_key', itemCategoryOptions, locale); - const artifactName = localizedName('ancient-artifacts', 'a', locale); - const artifactCategoryName = systemListJsonSql('a.category_key', ancientArtifactCategoryOptions, locale); + const artifactName = localizedName('items', 'artifact_item', locale); + const artifactCategoryName = systemListJsonSql('artifact_item.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale); const recipeItemName = localizedName('items', 'result_item', locale); const recipeMaterialName = localizedName('items', 'material_item', locale); const checklistTitle = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale); @@ -2596,16 +2629,17 @@ export async function globalSearch(paramsQuery: QueryParams = {}, locale = defau query( ` SELECT - a.id, + artifact_item.id, 'ancient-artifacts' AS type, ${artifactName} AS title, - '/ancient-artifacts/' || a.id AS url, - NULLIF(a.details, '') AS summary, + '/ancient-artifacts/' || artifact_item.id AS url, + NULLIF(artifact_item.details, '') AS summary, (${artifactCategoryName}->>'name') AS meta, - ${uploadedImageJson('a.image_path')} AS image - FROM ancient_artifacts a + ${uploadedImageJson('artifact_item.image_path')} AS image + FROM items artifact_item WHERE ${artifactName} ILIKE $1 - ORDER BY ${orderByEntity('a')} + AND artifact_item.ancient_artifact_category_key IS NOT NULL + ORDER BY ${orderByEntity('artifact_item')} LIMIT $2 `, [pattern, limit] @@ -4223,7 +4257,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 artifactName = localizedName('items', 'artifact_item', locale); const params: unknown[] = [user.id]; const outerConditions: string[] = []; @@ -4301,7 +4335,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 + LEFT JOIN items artifact_item ON edc.entity_type = 'ancient-artifacts' AND artifact_item.id = edc.entity_id WHERE edc.created_by_user_id = $1 AND edc.deleted_at IS NULL AND edc.ai_moderation_status = 'approved' @@ -6239,6 +6273,10 @@ function itemProjection(locale: string): string { ${itemDetails} AS details, i.details AS "baseDetails", i.base_price AS "basePrice", + CASE + WHEN i.ancient_artifact_category_key IS NULL THEN NULL + ELSE ${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)} + END AS "ancientArtifactCategory", i.is_event_item AS "isEventItem", ${translationsSelect('items', 'i.id')} AS translations, ${auditSelect('i', 'item_created_user', 'item_updated_user')}, @@ -6289,6 +6327,7 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale const conditions: string[] = []; const categoryId = Number(asString(paramsQuery.categoryId)); const usageId = Number(asString(paramsQuery.usageId)); + const ancientArtifactCategoryId = Number(asString(paramsQuery.ancientArtifactCategoryId)); const isEventItem = asString(paramsQuery.isEventItem); const tagIds = parseIdList(asString(paramsQuery.tagIds)); const search = asString(paramsQuery.search)?.trim(); @@ -6299,6 +6338,9 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale const usageOption = Number.isInteger(usageId) && usageId > 0 ? systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired') : null; + const ancientArtifactCategoryOption = Number.isInteger(ancientArtifactCategoryId) && ancientArtifactCategoryId > 0 + ? systemListOptionById(ancientArtifactCategoryOptions, ancientArtifactCategoryId, 'server.validation.invalidField') + : null; if (search) { params.push(`%${search}%`); @@ -6320,6 +6362,11 @@ export async function listItems(paramsQuery: QueryParams, locale = defaultLocale conditions.push(`i.usage_key = $${params.length}`); } + if (ancientArtifactCategoryOption) { + params.push(ancientArtifactCategoryOption.key); + conditions.push(`i.ancient_artifact_category_key = $${params.length}`); + } + const tagFilter = sqlForRelationFilter( tagIds, 'any', @@ -6495,6 +6542,11 @@ function cleanItemPayload(payload: Record): ItemPayload { const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined ? null : requirePositiveInteger(payload.usageId, 'server.validation.usageRequired'); + const ancientArtifactCategory = cleanOptionalSystemListOption( + payload.ancientArtifactCategoryId, + ancientArtifactCategoryOptions, + 'server.validation.invalidField' + ); const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId); const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId); const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired'); @@ -6508,6 +6560,8 @@ function cleanItemPayload(payload: Record): ItemPayload { name: cleanName(payload.name, 'server.validation.itemNameRequired'), details: cleanOptionalText(payload.details), basePrice: cleanOptionalNonNegativeInteger(payload.basePrice, 'server.validation.invalidField'), + ancientArtifactCategoryId: ancientArtifactCategory?.id ?? null, + ancientArtifactCategoryKey: ancientArtifactCategory?.key ?? null, translations: cleanTranslations(payload.translations, ['name', 'details']), categoryId, categoryKey: category.key, @@ -6520,7 +6574,7 @@ function cleanItemPayload(payload: Record): ItemPayload { isEventItem: Boolean(payload.isEventItem), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), tagIds: cleanIds(payload.tagIds), - imagePath: cleanUploadImagePath(payload.imagePath, 'items'), + imagePath: cleanItemOrArtifactImagePath(payload.imagePath), insertBeforeItemId, insertAfterItemId }; @@ -6582,6 +6636,7 @@ export async function createItem(payload: Record, userId: numbe INSERT INTO items ( name, details, + ancient_artifact_category_key, base_price, category_key, usage_key, @@ -6595,12 +6650,13 @@ 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, $12, $13, $13) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $14) RETURNING id `, [ cleanPayload.name, cleanPayload.details, + cleanPayload.ancientArtifactCategoryKey, cleanPayload.basePrice, cleanPayload.categoryKey, cleanPayload.usageKey, @@ -6657,22 +6713,24 @@ export async function updateItem(id: number, payload: Record, u UPDATE items SET name = $1, details = $2, - base_price = $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, + ancient_artifact_category_key = $3, + base_price = $4, + category_key = $5, + usage_key = $6, + dyeable = $7, + dual_dyeable = $8, + pattern_editable = $9, + no_recipe = $10, + is_event_item = $11, + image_path = $12, + updated_by_user_id = $13, updated_at = now() - WHERE id = $13 + WHERE id = $14 `, [ cleanPayload.name, cleanPayload.details, + cleanPayload.ancientArtifactCategoryKey, cleanPayload.basePrice, cleanPayload.categoryKey, cleanPayload.usageKey, @@ -6707,6 +6765,7 @@ export async function deleteItem(id: number, userId: number) { } await deleteEntityDiscussionCommentsForEntity(client, 'items', id); + await deleteEntityDiscussionCommentsForEntity(client, 'ancient-artifacts', id); await deleteEntityTranslations(client, 'items', id); await recordEditLog(client, 'items', id, 'delete', userId); return true; @@ -6714,29 +6773,29 @@ 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 artifactName = localizedName('items', 'i', locale); + const artifactDetails = localizedField('items', 'i.id', 'i.details', 'details', locale); const tagName = localizedName('favorite-things', 't', locale); return ` SELECT - a.id, + i.id, ${artifactName} AS name, - a.name AS "baseName", + i.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, + i.details AS "baseDetails", + ${translationsSelect('items', 'i.id')} AS translations, + ${systemListJsonSql('i.ancient_artifact_category_key', ancientArtifactCategoryOptions, locale)} AS category, + ${uploadedImageJson('i.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 + FROM item_favorite_things ift + JOIN favorite_things t ON t.id = ift.favorite_thing_id + WHERE ift.item_id = i.id ), '[]'::json) AS tags, - ${auditSelect('a', 'artifact_created_user', 'artifact_updated_user')} - FROM ancient_artifacts a - ${auditJoins('a', 'artifact_created_user', 'artifact_updated_user')} + ${auditSelect('i', 'item_created_user', 'item_updated_user')} + FROM items i + ${auditJoins('i', 'item_created_user', 'item_updated_user')} `; } @@ -6750,23 +6809,25 @@ export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale ? systemListOptionById(ancientArtifactCategoryOptions, categoryId, 'server.validation.categoryRequired') : null; + conditions.push('i.ancient_artifact_category_key IS NOT NULL'); + if (search) { params.push(`%${search}%`); - conditions.push(`${localizedName('ancient-artifacts', 'a', locale)} ILIKE $${params.length}`); + conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`); } if (categoryOption) { params.push(categoryOption.key); - conditions.push(`a.category_key = $${params.length}`); + conditions.push(`i.ancient_artifact_category_key = $${params.length}`); } const tagFilter = sqlForRelationFilter( tagIds, 'any', - 'ancient_artifact_favorite_things', - 'ancient_artifact_id', + 'item_favorite_things', + 'item_id', 'favorite_thing_id', - 'a.id', + 'i.id', params ); if (tagFilter) { @@ -6774,17 +6835,17 @@ export async function listAncientArtifacts(paramsQuery: QueryParams = {}, locale } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('a')}`, params); + return query(`${ancientArtifactProjection(locale)} ${whereClause} ORDER BY ${orderByEntity('i')}`, params); } export async function getAncientArtifact(id: number, locale = defaultLocale) { - const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE a.id = $1`, [id]); + const artifact = await queryOne(`${ancientArtifactProjection(locale)} WHERE i.id = $1 AND i.ancient_artifact_category_key IS NOT NULL`, [id]); if (!artifact) { return null; } - const editHistory = await getEditHistory('ancient-artifacts', id); - const imageHistory = await listEntityImageUploads('ancient-artifacts', id); + const editHistory = await getEditHistory('items', id); + const imageHistory = await listEntityImageUploads('items', id); return { ...artifact, editHistory, imageHistory }; } @@ -6799,16 +6860,16 @@ function cleanAncientArtifactPayload(payload: Record): AncientA categoryId, categoryKey: category.key, tagIds: cleanIds(payload.tagIds), - imagePath: cleanUploadImagePath(payload.imagePath, 'ancient-artifacts') + imagePath: cleanItemOrArtifactImagePath(payload.imagePath) }; } 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]); + await client.query('DELETE FROM item_favorite_things WHERE item_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)', + 'INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [artifactId, tagId] ); } @@ -6818,13 +6879,13 @@ export async function createAncientArtifact(payload: Record, us const cleanPayload = cleanAncientArtifactPayload(payload); const id = await withTransaction(async (client) => { - const sortOrder = await nextSortOrder(client, 'ancient_artifacts'); + const sortOrder = await nextSortOrder(client, 'items'); const result = await client.query<{ id: number }>( ` - INSERT INTO ancient_artifacts ( + INSERT INTO items ( name, details, - category_key, + ancient_artifact_category_key, image_path, sort_order, created_by_user_id, @@ -6843,10 +6904,10 @@ export async function createAncientArtifact(payload: Record, us ] ); const artifactId = result.rows[0].id; - await linkEntityImageUpload(client, 'ancient-artifacts', artifactId, cleanPayload.imagePath, cleanPayload.name); + await linkEntityImageUpload(client, 'items', 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); + await replaceEntityTranslations(client, 'items', artifactId, cleanPayload.translations, ['name', 'details']); + await recordEditLog(client, 'items', artifactId, 'create', userId); return artifactId; }); @@ -6860,14 +6921,15 @@ export async function updateAncientArtifact(id: number, payload: Record { const result = await client.query( ` - UPDATE ancient_artifacts + UPDATE items SET name = $1, details = $2, - category_key = $3, + ancient_artifact_category_key = $3, image_path = $4, updated_by_user_id = $5, updated_at = now() WHERE id = $6 + AND ancient_artifact_category_key IS NOT NULL `, [cleanPayload.name, cleanPayload.details, cleanPayload.categoryKey, cleanPayload.imagePath, userId, id] ); @@ -6875,11 +6937,11 @@ export async function updateAncientArtifact(id: number, payload: Record { - const result = await client.query<{ id: number }>('DELETE FROM ancient_artifacts WHERE id = $1 RETURNING id', [id]); + const result = await client.query<{ id: number }>('DELETE FROM items WHERE id = $1 AND ancient_artifact_category_key IS NOT NULL 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); + await deleteEntityTranslations(client, 'items', id); + await recordEditLog(client, 'items', id, 'delete', userId); return true; }); } @@ -7564,11 +7626,10 @@ export async function reorderDishes(payload: Record, userId: nu } const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[]; -const dataToolMainTables: Record = { +const dataToolMainTables: Record, string> = { pokemon: 'pokemon', habitats: 'habitats', items: 'items', - artifacts: 'ancient_artifacts', recipes: 'recipes', checklist: 'daily_checklist_items' }; @@ -7615,6 +7676,7 @@ const dataToolColumns = { 'name', 'details', 'base_price', + 'ancient_artifact_category_key', 'category_key', 'usage_key', 'dyeable', @@ -7631,19 +7693,7 @@ const dataToolColumns = { ], itemAcquisitionMethods: ['item_id', 'acquisition_method_id'], itemFavoriteThings: ['item_id', 'favorite_thing_id'], - artifactFavoriteThings: ['ancient_artifact_id', 'favorite_thing_id'], - artifacts: [ - 'id', - 'name', - 'details', - 'category_key', - 'image_path', - 'sort_order', - 'created_by_user_id', - 'updated_by_user_id', - 'created_at', - 'updated_at' - ], + artifacts: [] as string[], 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'], @@ -7691,6 +7741,7 @@ function normalizeDataToolScopes(scopes: DataToolScope[]): DataToolScope[] { const scopeSet = new Set(scopes); if (scopeSet.has('items')) { scopeSet.add('recipes'); + scopeSet.delete('artifacts'); } return dataToolScopes.filter((scope) => scopeSet.has(scope)); } @@ -7749,6 +7800,36 @@ function dataToolDataWithRows(key: string, ...sources: Array source?.[key] !== undefined); } +function dataToolArtifactRows(data: DataToolScopeData | undefined): DataToolRows { + return dataToolTableRows(data, 'artifacts').map((row) => { + if (row.ancient_artifact_category_key !== undefined) { + return row; + } + + return { + ...row, + base_price: null, + ancient_artifact_category_key: row.category_key, + category_key: 'other', + usage_key: null, + dyeable: false, + dual_dyeable: false, + pattern_editable: false, + no_recipe: false, + is_event_item: false + }; + }); +} + +function dataToolArtifactFavoriteThingRows(data: DataToolScopeData | undefined): DataToolRows { + const itemRows = dataToolTableRows(data, 'itemFavoriteThings'); + const artifactRows = dataToolTableRows(data, 'artifactFavoriteThings').map((row) => ({ + item_id: row.ancient_artifact_id, + favorite_thing_id: row.favorite_thing_id + })); + return [...itemRows, ...artifactRows]; +} + async function tableRows(client: DbClient, sql: string, params: unknown[] = []): Promise { const result = await client.query>(sql, params); return result.rows; @@ -7784,6 +7865,27 @@ async function insertRows(client: DbClient, tableName: string, columns: readonly } } +async function upsertRowsById(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise { + const updateColumns = columns.filter((column) => column !== 'id'); + for (const row of rows) { + const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); + const assignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', '); + const values = columns.map((column) => normalizeImportValue(column, row[column], row)); + await client.query( + `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${assignments}`, + values + ); + } +} + +async function insertRowsIgnoreConflicts(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], row)); + await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values); + } +} + async function resetIdentity(client: DbClient, tableName: string): Promise { const result = await client.query<{ maxId: number | null }>(`SELECT MAX(id)::integer AS "maxId" FROM ${tableName}`); const maxId = result.rows[0]?.maxId ?? null; @@ -7799,7 +7901,6 @@ async function resetDataToolIdentities(client: DbClient): Promise { for (const tableName of [ 'daily_checklist_items', 'items', - 'ancient_artifacts', 'recipes', 'habitats', 'wiki_edit_logs', @@ -7835,9 +7936,17 @@ async function wipeItemsData(client: DbClient): Promise { } 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'); + await client.query(` + DELETE FROM entity_discussion_comments + WHERE entity_type = 'ancient-artifacts' + AND entity_id IN (SELECT id FROM items WHERE ancient_artifact_category_key IS NOT NULL) + `); + await client.query(` + UPDATE items + SET ancient_artifact_category_key = NULL, + updated_at = now() + WHERE ancient_artifact_category_key IS NOT NULL + `); } async function wipePokemonData(client: DbClient): Promise { @@ -7955,12 +8064,73 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise< if (scope === 'artifacts') { return { - artifacts: await tableRows(client, 'SELECT * FROM ancient_artifacts ORDER BY sort_order, id'), - artifactFavoriteThings: await tableRows( + artifacts: await tableRows(client, 'SELECT * FROM items WHERE ancient_artifact_category_key IS NOT NULL ORDER BY sort_order, id'), + itemFavoriteThings: await tableRows( client, - 'SELECT * FROM ancient_artifact_favorite_things ORDER BY ancient_artifact_id, favorite_thing_id' + ` + SELECT ift.* + FROM item_favorite_things ift + JOIN items i ON i.id = ift.item_id + WHERE i.ancient_artifact_category_key IS NOT NULL + ORDER BY ift.item_id, ift.favorite_thing_id + ` ), - ...(await exportGenericScopeData(client, 'ancient-artifacts', true)) + translations: await tableRows( + client, + ` + SELECT et.* + FROM entity_translations et + JOIN items i ON i.id = et.entity_id + WHERE et.entity_type = 'items' + AND i.ancient_artifact_category_key IS NOT NULL + ORDER BY et.entity_id, et.locale, et.field_name + ` + ), + editLogs: await tableRows( + client, + ` + SELECT wel.* + FROM wiki_edit_logs wel + JOIN items i ON i.id = wel.entity_id + WHERE wel.entity_type = 'items' + AND i.ancient_artifact_category_key IS NOT NULL + ORDER BY wel.id + ` + ), + imageUploads: await tableRows( + client, + ` + SELECT eiu.* + FROM entity_image_uploads eiu + JOIN items i ON i.id = eiu.entity_id + WHERE eiu.entity_type = 'items' + AND i.ancient_artifact_category_key IS NOT NULL + ORDER BY eiu.id + ` + ), + discussionComments: await tableRows( + client, + ` + SELECT edc.* + FROM entity_discussion_comments edc + JOIN items i ON i.id = edc.entity_id + WHERE edc.entity_type = 'ancient-artifacts' + AND i.ancient_artifact_category_key IS NOT NULL + ORDER BY edc.parent_comment_id NULLS FIRST, edc.id + ` + ), + discussionCommentLikes: await tableRows( + client, + ` + SELECT edcl.* + FROM entity_discussion_comment_likes edcl + JOIN entity_discussion_comments edc ON edc.id = edcl.comment_id + JOIN items i ON i.id = edc.entity_id + WHERE edc.entity_type = 'ancient-artifacts' + AND i.ancient_artifact_category_key IS NOT NULL + ORDER BY edcl.comment_id, edcl.user_id + ` + ) }; } @@ -7993,7 +8163,7 @@ async function importScopeMainRows(client: DbClient, bundle: DataToolsBundle): P 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 upsertRowsById(client, 'items', dataToolColumns.items, dataToolArtifactRows(artifactData)); 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')); @@ -8012,12 +8182,7 @@ 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 insertRowsIgnoreConflicts(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolArtifactFavoriteThingRows(artifactData)); 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')); @@ -8054,7 +8219,11 @@ async function importDataToolsBundle(client: DbClient, bundle: DataToolsBundle): export async function getAdminDataToolsSummary(): Promise<{ scopes: DataToolScopeSummary[] }> { const scopes: DataToolScopeSummary[] = []; for (const scope of dataToolScopes) { - const result = await queryOne<{ count: number }>(`SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}`); + const result = await queryOne<{ count: number }>( + scope === 'artifacts' + ? 'SELECT COUNT(*)::integer AS count FROM items WHERE ancient_artifact_category_key IS NOT NULL' + : `SELECT COUNT(*)::integer AS count FROM ${dataToolMainTables[scope]}` + ); scopes.push({ scope, count: result?.count ?? 0 }); } return { scopes }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index fa2e9f0..f533bd6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -7,7 +7,6 @@ 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'; @@ -200,7 +199,7 @@ export const router = createRouter({ name: 'ancient-artifact-new', component: AncientArtifactList, meta: { - requiredPermission: 'ancient-artifacts.create', + requiredPermission: 'items.create', editorModal: true, seo: seo({ titleKey: 'pages.ancientArtifacts.newTitle', @@ -213,9 +212,9 @@ export const router = createRouter({ { path: '/ancient-artifacts/:id/edit', name: 'ancient-artifact-edit', - component: AncientArtifactDetail, + component: ItemDetail, meta: { - requiredPermission: 'ancient-artifacts.update', + requiredPermission: 'items.update', editorModal: true, seo: seo({ titleKey: 'pages.ancientArtifacts.editKicker', @@ -228,7 +227,7 @@ export const router = createRouter({ { path: '/ancient-artifacts/:id', name: 'ancient-artifact-detail', - component: AncientArtifactDetail, + component: ItemDetail, 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' }) } }, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 46b5df5..3ba1739 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -258,6 +258,7 @@ export interface Item extends EditInfo { details: string; baseDetails?: string; basePrice: number | null; + ancientArtifactCategory: NamedEntity | null; isEventItem: boolean; translations?: TranslationMap; image: EntityImage | null; @@ -791,6 +792,7 @@ export interface ItemPayload { name: string; details: string; basePrice: number | null; + ancientArtifactCategoryId: number | null; translations?: TranslationMap; categoryId: number; usageId: number | null; diff --git a/frontend/src/views/AncientArtifactDetail.vue b/frontend/src/views/AncientArtifactDetail.vue deleted file mode 100644 index 162cbc3..0000000 --- a/frontend/src/views/AncientArtifactDetail.vue +++ /dev/null @@ -1,166 +0,0 @@ - - - diff --git a/frontend/src/views/AncientArtifactEdit.vue b/frontend/src/views/AncientArtifactEdit.vue deleted file mode 100644 index edf0d6b..0000000 --- a/frontend/src/views/AncientArtifactEdit.vue +++ /dev/null @@ -1,261 +0,0 @@ - - - diff --git a/frontend/src/views/AncientArtifactList.vue b/frontend/src/views/AncientArtifactList.vue index 68d8b81..03398a9 100644 --- a/frontend/src/views/AncientArtifactList.vue +++ b/frontend/src/views/AncientArtifactList.vue @@ -11,7 +11,7 @@ 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'; +import ItemEdit from './ItemEdit.vue'; const route = useRoute(); const { t } = useI18n(); @@ -37,7 +37,7 @@ const artifactQuery = computed(() => ({ tagIds: tagIds.value.join(',') })); const showEditor = computed(() => route.name === 'ancient-artifact-new'); -const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('ancient-artifacts.create') === true); +const canCreateArtifact = computed(() => currentUser.value?.permissions.includes('items.create') === true); function artifactCardImage(artifact: AncientArtifact) { return artifact.image ? { src: artifact.image.url, alt: t('media.imageAlt', { name: artifact.name }) } : undefined; @@ -139,6 +139,6 @@ watch(artifactQuery, loadArtifacts); /> - + diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 85636a3..bd49f2f 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -2,7 +2,7 @@ import { Icon } from '@iconify/vue'; import { computed, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import DetailSection from '../components/DetailSection.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EntityDiscussionPanel from '../components/EntityDiscussionPanel.vue'; @@ -17,11 +17,13 @@ import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/a import ItemEdit from './ItemEdit.vue'; const route = useRoute(); +const router = useRouter(); const { locale, t } = useI18n(); const item = ref(null); const currentUser = ref(null); const detailTab = ref('details'); -const showEditor = computed(() => route.name === 'item-edit'); +const isAncientArtifactRoute = computed(() => route.name === 'ancient-artifact-detail' || route.name === 'ancient-artifact-edit'); +const showEditor = computed(() => route.name === 'item-edit' || route.name === 'ancient-artifact-edit'); const canUpdateItem = computed(() => currentUser.value?.permissions.includes('items.update') === true); const canCreateRecipe = computed(() => currentUser.value?.permissions.includes('recipes.create') === true); const detailTabs = computed(() => [ @@ -36,8 +38,26 @@ 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 detailKicker = computed(() => + isAncientArtifactRoute.value + ? t('pages.ancientArtifacts.detailKicker') + : item.value?.isEventItem + ? t('pages.eventItems.detailKicker') + : t('pages.items.detailKicker') +); +const listTarget = computed(() => (isAncientArtifactRoute.value ? '/ancient-artifacts' : item.value?.isEventItem ? '/event-items' : '/items')); +const editTarget = computed(() => + item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}/edit` : `/items/${item.value.id}/edit`) : '' +); +const detailCanonicalPath = computed(() => + item.value ? (isAncientArtifactRoute.value ? `/ancient-artifacts/${item.value.id}` : `/items/${item.value.id}`) : '' +); +const detailTitleKey = computed(() => + isAncientArtifactRoute.value ? 'pages.ancientArtifacts.title' : item.value?.isEventItem ? 'pages.eventItems.title' : 'pages.items.title' +); +const detailDescriptionKey = computed(() => + isAncientArtifactRoute.value ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription' +); const basePriceDisplay = computed(() => { const price = item.value?.basePrice; return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price); @@ -57,13 +77,19 @@ const customization = computed(() => { async function loadItemDetail() { const nextItem = await api.itemDetail(String(route.params.id)); + + if (isAncientArtifactRoute.value && !nextItem.ancientArtifactCategory) { + await router.replace(route.name === 'ancient-artifact-edit' ? `/items/${nextItem.id}/edit` : `/items/${nextItem.id}`); + return; + } + item.value = nextItem; if (route.meta.editorModal !== true) { applySeo({ - title: `${nextItem.name} - ${t(nextItem.isEventItem ? 'pages.eventItems.title' : 'pages.items.title')}`, - description: t('seo.itemDetailDescription', { name: nextItem.name }), - canonicalPath: `/items/${nextItem.id}`, + title: `${nextItem.name} - ${t(detailTitleKey.value)}`, + description: t(detailDescriptionKey.value, { name: nextItem.name }), + canonicalPath: detailCanonicalPath.value, image: nextItem.image?.url }); } @@ -83,7 +109,10 @@ onMounted(async () => { watch( () => route.name, (name, oldName) => { - if (oldName === 'item-edit' && name === 'item-detail') { + if ( + (oldName === 'item-edit' && name === 'item-detail') || + (oldName === 'ancient-artifact-edit' && name === 'ancient-artifact-detail') + ) { void loadItemDetail(); } } @@ -156,7 +185,7 @@ watch(