From 2220d5d595362523a6bdda7a9e712360cd3a96dd Mon Sep 17 00:00:00 2001 From: xiaomai Date: Mon, 4 May 2026 21:00:23 +0800 Subject: [PATCH] feat(dish): add dish management and public view Add database schema, permissions, and API endpoints for dishes Implement frontend views and admin management for dish data --- DESIGN.md | 44 ++- backend/db/schema.sql | 164 ++++++++- backend/src/queries.ts | 546 ++++++++++++++++++++++++++- backend/src/server.ts | 75 ++++ frontend/src/App.vue | 2 +- frontend/src/router/index.ts | 6 +- frontend/src/services/api.ts | 69 +++- frontend/src/styles/main.css | 150 ++++++++ frontend/src/views/AdminView.vue | 445 +++++++++++++++++++++- frontend/src/views/DishView.vue | 609 +++++++++++++++++++++++++++++++ frontend/vite.config.ts | 2 +- system-wordings.ts | 60 ++- 12 files changed, 2147 insertions(+), 25 deletions(-) create mode 100644 frontend/src/views/DishView.vue diff --git a/DESIGN.md b/DESIGN.md index 462fb6a..f512623 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -65,11 +65,16 @@ - 每日 CheckList Task - Life Category - Game Version + - Dish Category + - Dish Flavor + - Dish - 支持翻译的字段: - `name` - `title` - `details`:Pokemon、物品和 Ancient Artifacts 的介绍 / 说明 - `genus`:仅 Pokemon Genus 使用 + - `effect`:Dish Category 的吃后效果 + - `mosslaxEffect`:Dish 给 Mosslax 吃之后的效果 - 实体仍保留基础 `name`、`title`、`details` 或 `genus` 字段,默认语言内容以基础字段为准。 - API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。 - 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。 @@ -729,6 +734,43 @@ Ancient Artifacts 详情页展示: - 讨论 - 编辑历史 +## Dish + +Dish 是公开浏览的料理资料入口,按可配置分类组织。 + +Dish Category 可配置: + +- 名称 +- 厨具:关联 Items +- 主材料:关联 Items,必填 +- 吃了之后的效果 +- 总数所需材料数量:最小值为 2 +- 翻译 +- 排序 + +Dish 可配置: + +- 所属 Dish Category +- 菜肴:关联 Items +- 味道:使用 System Config 中可配置的 Dish Flavor +- 副材料:关联 Items,可选 +- 第二副材料:关联 Items,仅当所属分类的总数所需材料数量大于 2 时可配置 +- Pokemon 特征:可选,复用现有特长配置 +- 给苔藓卡比兽(Mosslax)吃之后的效果 +- 翻译 +- 排序 + +Dish 页面功能: + +- `/dish` 是公开浏览入口。 +- 分类使用 Tabs 展示。 +- `/dish` 可直接添加、编辑和删除 Dish Category 与 Dish;写入入口按 `dish.*` 权限展示,后端仍做权限校验。 +- 每个分类第一行展示分类名、厨具、主材料和总数所需材料数量;第二行展示吃后效果。 +- 每个菜肴展示菜肴物品、味道、可选副材料、可选第二副材料、可选 Pokemon 特征和 Mosslax 效果。 +- Item、特长和 Dish Flavor 名称按当前语言解析;Dish Category 名称、吃后效果和 Dish Mosslax 效果按当前语言解析。 +- Dish 公开 API 只返回浏览需要的 Item、特长、材料、效果和审计字段,不返回内部字段、权限、token/hash 或调试信息。 +- Dish 分类和菜肴的创建、更新、删除、排序必须记录编辑历史和编辑者信息。 + ## 栖息地 栖息地可配置: @@ -881,7 +923,6 @@ API 暴露边界: 以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力: - Automation:未来用于分享自动化基地(亦称工厂)创建方案、材料产出、所需 Pokemon、生产顺序和共同喜好物品。 -- Dish - Events - Actions:游戏内快捷动作,例如挥手、跳舞等。 - Dream Island @@ -1014,6 +1055,7 @@ API 暴露边界: - `GET /api/ancient-artifacts/:id` - `GET /api/recipes` - `GET /api/recipes/:id` +- `GET /api/dish` - `GET /api/life-posts`:支持 `cursor` / `limit` 分页读取;支持 `search` 按 Life Post 正文搜索;支持 `categoryId` 按 Life Category 筛选;支持 `language` 按审核语言区筛选,`all` 表示全部语言区;支持 `gameVersionId` 按 Game Version 筛选;支持 `rateable` 按可评分 Category 筛选;支持 `sort` 为 `latest`、`oldest` 或 `top-rated`。 - `GET /api/life-posts/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。 - `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 07b7dce..3a73139 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -35,12 +35,15 @@ CREATE TABLE IF NOT EXISTS entity_translations ( 'habitats', 'daily-checklist-items', 'life-tags', - 'game-versions' + 'game-versions', + 'dish-categories', + 'dish-flavors', + 'dishes' ) ), entity_id integer NOT NULL, locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE, - field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')), + field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect')), value text NOT NULL, PRIMARY KEY (entity_type, entity_id, locale, field_name) ); @@ -68,10 +71,21 @@ ALTER TABLE entity_translations 'habitats', 'daily-checklist-items', 'life-tags', - 'game-versions' + 'game-versions', + 'dish-categories', + 'dish-flavors', + 'dishes' ) ); +ALTER TABLE entity_translations + DROP CONSTRAINT IF EXISTS entity_translations_field_name_check; + +ALTER TABLE entity_translations + ADD CONSTRAINT entity_translations_field_name_check CHECK ( + field_name IN ('name', 'title', 'details', 'genus', 'effect', 'mosslaxEffect') + ); + CREATE TABLE IF NOT EXISTS users ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, @@ -291,6 +305,10 @@ VALUES ('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true), ('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true), ('recipes.order', 'Order recipes', 'Reorder recipe records.', 'Recipes', true), + ('dish.create', 'Create Dish records', 'Create Dish categories and dish records.', 'Dish', true), + ('dish.update', 'Update Dish records', 'Edit Dish categories and dish records.', 'Dish', true), + ('dish.delete', 'Delete Dish records', 'Delete Dish categories and dish records.', 'Dish', true), + ('dish.order', 'Order Dish records', 'Reorder Dish categories and dish records.', 'Dish', true), ('life.posts.create', 'Create Life posts', 'Create Life posts.', 'Life', true), ('life.posts.update', 'Update own Life posts', 'Edit own Life posts.', 'Life', true), ('life.posts.delete', 'Delete own Life posts', 'Delete own Life posts.', 'Life', true), @@ -385,6 +403,10 @@ JOIN permissions p ON p.key = ANY (ARRAY[ 'recipes.update', 'recipes.delete', 'recipes.order', + 'dish.create', + 'dish.update', + 'dish.delete', + 'dish.order', 'life.posts.create', 'life.posts.update', 'life.posts.delete', @@ -459,6 +481,9 @@ JOIN permissions p ON p.key = ANY (ARRAY[ 'recipes.create', 'recipes.update', 'recipes.order', + 'dish.create', + 'dish.update', + 'dish.order', 'life.posts.create', 'life.posts.update', 'life.posts.delete', @@ -505,6 +530,29 @@ JOIN permissions p ON p.key = ANY (ARRAY[ WHERE r.key = 'editor' ON CONFLICT DO NOTHING; +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.key = ANY (ARRAY[ + 'dish.create', + 'dish.update', + 'dish.delete', + 'dish.order' +]) +WHERE r.key = 'admin' +ON CONFLICT DO NOTHING; + +INSERT INTO role_permissions (role_id, permission_id) +SELECT r.id, p.id +FROM roles r +JOIN permissions p ON p.key = ANY (ARRAY[ + 'dish.create', + 'dish.update', + 'dish.order' +]) +WHERE r.key = 'editor' +ON CONFLICT DO NOTHING; + INSERT INTO role_permissions (role_id, permission_id) SELECT r.id, p.id FROM roles r @@ -1024,6 +1072,112 @@ CREATE TABLE IF NOT EXISTS recipe_materials ( PRIMARY KEY (recipe_id, item_id) ); +CREATE TABLE IF NOT EXISTS dish_categories ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE, + cookware_item_id integer NOT NULL REFERENCES items(id), + main_material_item_id integer NOT NULL REFERENCES items(id), + total_material_quantity integer NOT NULL DEFAULT 2 CHECK (total_material_quantity >= 2), + effect text NOT NULL DEFAULT '', + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +ALTER TABLE dish_categories + ADD COLUMN IF NOT EXISTS main_material_item_id integer REFERENCES items(id); + +ALTER TABLE dish_categories + ADD COLUMN IF NOT EXISTS total_material_quantity integer NOT NULL DEFAULT 2; + +DO $$ +BEGIN + IF to_regclass('public.dishes') IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'dishes' + AND column_name = 'main_material_item_id' + ) + THEN + EXECUTE ' + UPDATE dish_categories dc + SET main_material_item_id = source.main_material_item_id + FROM ( + SELECT DISTINCT ON (category_id) category_id, main_material_item_id + FROM dishes + WHERE main_material_item_id IS NOT NULL + ORDER BY category_id, sort_order, id + ) AS source + WHERE dc.id = source.category_id + AND dc.main_material_item_id IS NULL + '; + END IF; +END $$; + +UPDATE dish_categories +SET main_material_item_id = cookware_item_id +WHERE main_material_item_id IS NULL; + +ALTER TABLE dish_categories + ALTER COLUMN main_material_item_id SET NOT NULL; + +ALTER TABLE dish_categories + ALTER COLUMN total_material_quantity SET DEFAULT 2; + +UPDATE dish_categories +SET total_material_quantity = 2 +WHERE total_material_quantity < 2; + +ALTER TABLE dish_categories + DROP CONSTRAINT IF EXISTS dish_categories_total_material_quantity_check; + +ALTER TABLE dish_categories + ADD CONSTRAINT dish_categories_total_material_quantity_check CHECK (total_material_quantity >= 2); + +CREATE TABLE IF NOT EXISTS dish_flavors ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE, + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS dishes ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + category_id integer NOT NULL REFERENCES dish_categories(id) ON DELETE CASCADE, + item_id integer NOT NULL UNIQUE REFERENCES items(id), + flavor_id integer NOT NULL REFERENCES dish_flavors(id), + secondary_material_1_item_id integer REFERENCES items(id), + secondary_material_2_item_id integer REFERENCES items(id), + pokemon_skill_id integer REFERENCES skills(id), + mosslax_effect text NOT NULL DEFAULT '', + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK ( + secondary_material_1_item_id IS NULL + OR secondary_material_2_item_id IS NULL + OR secondary_material_1_item_id <> secondary_material_2_item_id + ) +); + +ALTER TABLE dishes + ADD COLUMN IF NOT EXISTS flavor_id integer REFERENCES dish_flavors(id); + +ALTER TABLE dishes + ALTER COLUMN secondary_material_1_item_id DROP NOT NULL; + +ALTER TABLE dishes + DROP COLUMN IF EXISTS main_material_item_id; + CREATE TABLE IF NOT EXISTS maps ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, @@ -1193,6 +1347,10 @@ CREATE INDEX IF NOT EXISTS items_display_order_idx ON items(is_event_item, displ CREATE INDEX IF NOT EXISTS ancient_artifacts_sort_order_idx ON ancient_artifacts(sort_order, id); CREATE INDEX IF NOT EXISTS ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id); CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id); +CREATE INDEX IF NOT EXISTS dish_categories_sort_order_idx ON dish_categories(sort_order, id); +CREATE INDEX IF NOT EXISTS dish_flavors_sort_order_idx ON dish_flavors(sort_order, id); +CREATE INDEX IF NOT EXISTS dishes_category_sort_order_idx ON dishes(category_id, sort_order, id); +CREATE INDEX IF NOT EXISTS dishes_sort_order_idx ON dishes(sort_order, id); CREATE INDEX IF NOT EXISTS maps_sort_order_idx ON maps(sort_order, id); CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index b7576f6..de8e7f5 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -63,7 +63,7 @@ type GlobalSearchResults = { groups: GlobalSearchGroup[]; }; -type TranslationField = 'name' | 'title' | 'details' | 'genus'; +type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect'; type TranslationInput = Record>>; type EntityType = | 'pokemon' @@ -80,7 +80,10 @@ type EntityType = | 'habitats' | 'daily-checklist-items' | 'life-tags' - | 'game-versions'; + | 'game-versions' + | 'dish-categories' + | 'dish-flavors' + | 'dishes'; type ConfigType = | 'pokemon-types' @@ -90,7 +93,8 @@ type ConfigType = | 'acquisition-methods' | 'maps' | 'life-tags' - | 'game-versions'; + | 'game-versions' + | 'dish-flavors'; type ConfigDefinition = { table: string; @@ -232,6 +236,25 @@ type RecipePayload = { materials: IdQuantity[]; }; +type DishCategoryPayload = { + name: string; + effect: string; + translations: TranslationInput; + cookwareItemId: number; + mainMaterialItemId: number; + totalMaterialQuantity: number; +}; + +type DishPayload = { + categoryId: number; + itemId: number; + flavorId: number; + secondaryMaterialItemIds: number[]; + pokemonSkillId: number | null; + mosslaxEffect: string; + translations: TranslationInput; +}; + type DailyChecklistPayload = { title: string; translations: TranslationInput; @@ -550,6 +573,25 @@ type RecipeChangeSource = { acquisition_methods: Array<{ name: string }>; materials: Array<{ name: string; quantity: number }>; }; + +type DishCategoryChangeSource = { + name: string; + effect: string; + translations?: TranslationInput; + cookware: { name: string }; + mainMaterial: { name: string }; + totalMaterialQuantity: number; +}; + +type DishChangeSource = { + category: { name: string }; + item: { name: string }; + flavor: { name: string }; + secondaryMaterials: Array<{ name: string }>; + pokemonSkill: { name: string } | null; + mosslaxEffect: string; + translations?: TranslationInput; +}; type DailyChecklistChangeSource = { title: string; } & TranslationChangeSource; @@ -625,7 +667,8 @@ const configDefinitions: Record = { '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 }, - 'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true } + 'game-versions': { table: 'game_versions', entityType: 'game-versions', hasChangeLog: true }, + 'dish-flavors': { table: 'dish_flavors', entityType: 'dish-flavors' } }; const sortableContentDefinitions: Record = { @@ -2008,7 +2051,9 @@ const translationChangeLabels: Record = { name: 'Name', title: 'Title', details: 'Details', - genus: 'Genus' + genus: 'Genus', + effect: 'Effect', + mosslaxEffect: 'Mosslax effect' }; function translationFieldValue( @@ -2414,7 +2459,8 @@ export async function getOptions(locale = defaultLocale) { acquisitionMethods, maps, lifeCategories, - gameVersions + gameVersions, + dishFlavors ] = await Promise.all([ optionSelect('pokemon_types', 'pokemon-types', locale), skillOptions(locale), @@ -2423,7 +2469,8 @@ export async function getOptions(locale = defaultLocale) { optionSelect('acquisition_methods', 'acquisition-methods', locale), optionSelect('maps', 'maps', locale), lifeCategoryOptions(locale), - gameVersionOptions(locale) + gameVersionOptions(locale), + optionSelect('dish_flavors', 'dish-flavors', locale) ]); return { @@ -2438,7 +2485,8 @@ export async function getOptions(locale = defaultLocale) { itemTags: favoriteThings, maps, lifeCategories, - gameVersions + gameVersions, + dishFlavors }; } @@ -6989,6 +7037,488 @@ export async function deleteRecipe(id: number, userId: number) { }); } +function dishCategoryProjection(locale: string): string { + const categoryName = localizedName('dish-categories', 'dc', locale); + const categoryEffect = localizedField('dish-categories', 'dc.id', 'dc.effect', 'effect', locale); + const cookwareName = localizedName('items', 'cookware_item', locale); + const mainMaterialName = localizedName('items', 'main_material_item', locale); + const dishItemName = localizedName('items', 'dish_item', locale); + const flavorName = localizedName('dish-flavors', 'dish_flavor', locale); + const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale); + const skillName = localizedName('skills', 'dish_skill', locale); + const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale); + + return ` + SELECT + dc.id, + ${categoryName} AS name, + dc.name AS "baseName", + ${categoryEffect} AS effect, + dc.effect AS "baseEffect", + dc.total_material_quantity AS "totalMaterialQuantity", + ${translationsSelect('dish-categories', 'dc.id')} AS translations, + ${auditSelect('dc', 'category_created_user', 'category_updated_user')}, + json_build_object( + 'id', cookware_item.id, + 'displayId', cookware_item.display_id, + 'name', ${cookwareName}, + 'image', ${uploadedImageJson('cookware_item.image_path')}, + 'category', ${systemListJsonSql('cookware_item.category_key', itemCategoryOptions, locale)} + ) AS cookware, + json_build_object( + 'id', main_material_item.id, + 'displayId', main_material_item.display_id, + 'name', ${mainMaterialName}, + 'image', ${uploadedImageJson('main_material_item.image_path')}, + 'category', ${systemListJsonSql('main_material_item.category_key', itemCategoryOptions, locale)} + ) AS "mainMaterial", + COALESCE(( + SELECT json_agg( + json_build_object( + 'id', d.id, + 'flavor', json_build_object('id', dish_flavor.id, 'name', ${flavorName}), + 'mosslaxEffect', ${mosslaxEffect}, + 'baseMosslaxEffect', d.mosslax_effect, + 'translations', ${translationsSelect('dishes', 'd.id')}, + 'createdAt', d.created_at, + 'updatedAt', d.updated_at, + 'createdBy', CASE + WHEN dish_created_user.id IS NULL THEN NULL + ELSE json_build_object('id', dish_created_user.id, 'displayName', dish_created_user.display_name) + END, + 'updatedBy', CASE + WHEN dish_updated_user.id IS NULL THEN NULL + ELSE json_build_object('id', dish_updated_user.id, 'displayName', dish_updated_user.display_name) + END, + 'category', json_build_object('id', dc.id, 'name', ${categoryName}), + 'item', json_build_object( + 'id', dish_item.id, + 'displayId', dish_item.display_id, + 'name', ${dishItemName}, + 'image', ${uploadedImageJson('dish_item.image_path')}, + 'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)} + ), + 'secondaryMaterials', COALESCE(( + SELECT json_agg( + json_build_object( + 'id', secondary_material_item.id, + 'displayId', secondary_material_item.display_id, + 'name', ${secondaryMaterialName}, + 'image', ${uploadedImageJson('secondary_material_item.image_path')}, + 'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)} + ) + ORDER BY secondary_slots.slot + ) + FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot) + JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id + ), '[]'::json), + 'pokemonSkill', CASE + WHEN dish_skill.id IS NULL THEN NULL + ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop) + END + ) + ORDER BY d.sort_order, d.id + ) + FROM dishes d + JOIN items dish_item ON dish_item.id = d.item_id + JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id + LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id + LEFT JOIN users dish_created_user ON dish_created_user.id = d.created_by_user_id + LEFT JOIN users dish_updated_user ON dish_updated_user.id = d.updated_by_user_id + WHERE d.category_id = dc.id + ), '[]'::json) AS dishes + FROM dish_categories dc + JOIN items cookware_item ON cookware_item.id = dc.cookware_item_id + JOIN items main_material_item ON main_material_item.id = dc.main_material_item_id + ${auditJoins('dc', 'category_created_user', 'category_updated_user')} + `; +} + +function dishProjection(locale: string): string { + const categoryName = localizedName('dish-categories', 'dc', locale); + const dishItemName = localizedName('items', 'dish_item', locale); + const flavorName = localizedName('dish-flavors', 'dish_flavor', locale); + const secondaryMaterialName = localizedName('items', 'secondary_material_item', locale); + const skillName = localizedName('skills', 'dish_skill', locale); + const mosslaxEffect = localizedField('dishes', 'd.id', 'd.mosslax_effect', 'mosslaxEffect', locale); + + return ` + SELECT + d.id, + json_build_object('id', dish_flavor.id, 'name', ${flavorName}) AS flavor, + ${mosslaxEffect} AS "mosslaxEffect", + d.mosslax_effect AS "baseMosslaxEffect", + ${translationsSelect('dishes', 'd.id')} AS translations, + ${auditSelect('d', 'dish_created_user', 'dish_updated_user')}, + json_build_object('id', dc.id, 'name', ${categoryName}) AS category, + json_build_object( + 'id', dish_item.id, + 'displayId', dish_item.display_id, + 'name', ${dishItemName}, + 'image', ${uploadedImageJson('dish_item.image_path')}, + 'category', ${systemListJsonSql('dish_item.category_key', itemCategoryOptions, locale)} + ) AS item, + COALESCE(( + SELECT json_agg( + json_build_object( + 'id', secondary_material_item.id, + 'displayId', secondary_material_item.display_id, + 'name', ${secondaryMaterialName}, + 'image', ${uploadedImageJson('secondary_material_item.image_path')}, + 'category', ${systemListJsonSql('secondary_material_item.category_key', itemCategoryOptions, locale)} + ) + ORDER BY secondary_slots.slot + ) + FROM (VALUES (d.secondary_material_1_item_id, 1), (d.secondary_material_2_item_id, 2)) AS secondary_slots(item_id, slot) + JOIN items secondary_material_item ON secondary_material_item.id = secondary_slots.item_id + ), '[]'::json) AS "secondaryMaterials", + CASE + WHEN dish_skill.id IS NULL THEN NULL + ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop) + END AS "pokemonSkill" + FROM dishes d + JOIN dish_categories dc ON dc.id = d.category_id + JOIN items dish_item ON dish_item.id = d.item_id + JOIN dish_flavors dish_flavor ON dish_flavor.id = d.flavor_id + LEFT JOIN skills dish_skill ON dish_skill.id = d.pokemon_skill_id + ${auditJoins('d', 'dish_created_user', 'dish_updated_user')} + `; +} + +export async function listDish(locale = defaultLocale) { + return query(`${dishCategoryProjection(locale)} ORDER BY ${orderByEntity('dc')}`); +} + +async function getDishCategory(id: number, locale = defaultLocale) { + return queryOne(`${dishCategoryProjection(locale)} WHERE dc.id = $1`, [id]); +} + +async function getDish(id: number, locale = defaultLocale) { + return queryOne(`${dishProjection(locale)} WHERE d.id = $1`, [id]); +} + +function cleanDishCategoryPayload(payload: Record): DishCategoryPayload { + const totalMaterialQuantity = requirePositiveInteger(payload.totalMaterialQuantity, 'server.validation.invalidField'); + if (totalMaterialQuantity < 2) { + throw validationError('server.validation.invalidField'); + } + + return { + name: cleanName(payload.name), + effect: cleanName(payload.effect, 'server.validation.invalidField'), + translations: cleanTranslations(payload.translations, ['name', 'effect']), + cookwareItemId: requirePositiveInteger(payload.cookwareItemId, 'server.validation.itemRequired'), + mainMaterialItemId: requirePositiveInteger(payload.mainMaterialItemId, 'server.validation.itemRequired'), + totalMaterialQuantity + }; +} + +function cleanDishPayload(payload: Record): DishPayload { + const secondaryMaterialItemIds = cleanIds(payload.secondaryMaterialItemIds).slice(0, 2); + + return { + categoryId: requirePositiveInteger(payload.categoryId, 'server.validation.categoryRequired'), + itemId: requirePositiveInteger(payload.itemId, 'server.validation.itemRequired'), + flavorId: requirePositiveInteger(payload.flavorId, 'server.validation.invalidField'), + secondaryMaterialItemIds, + pokemonSkillId: optionalPositiveInteger(payload.pokemonSkillId, 'server.validation.invalidField'), + mosslaxEffect: cleanName(payload.mosslaxEffect, 'server.validation.invalidField'), + translations: cleanTranslations(payload.translations, ['mosslaxEffect']) + }; +} + +async function ensureDishMaterialSlots(client: DbClient, payload: DishPayload): Promise { + const result = await client.query<{ totalMaterialQuantity: number; mainMaterialItemId: number }>( + ` + SELECT + total_material_quantity AS "totalMaterialQuantity", + main_material_item_id AS "mainMaterialItemId" + FROM dish_categories + WHERE id = $1 + `, + [payload.categoryId] + ); + if (result.rowCount === 0) { + throw validationError('server.validation.categoryRequired'); + } + + if (payload.secondaryMaterialItemIds.length > 1 && result.rows[0].totalMaterialQuantity <= 2) { + throw validationError('server.validation.invalidField'); + } + + if (payload.secondaryMaterialItemIds.includes(result.rows[0].mainMaterialItemId)) { + throw validationError('server.validation.invalidField'); + } +} + +async function ensureDishCategoryMaterialSlots(client: DbClient, id: number, payload: DishCategoryPayload): Promise { + const result = await client.query<{ id: number }>( + ` + SELECT id + FROM dishes + WHERE category_id = $1 + AND ( + ($2::integer <= 2 AND secondary_material_2_item_id IS NOT NULL) + OR secondary_material_1_item_id = $3 + OR secondary_material_2_item_id = $3 + ) + LIMIT 1 + `, + [id, payload.totalMaterialQuantity, payload.mainMaterialItemId] + ); + if ((result.rowCount ?? 0) > 0) { + throw validationError('server.validation.invalidField'); + } +} + +async function dishCategoryEditChanges( + client: DbClient, + before: DishCategoryChangeSource, + after: DishCategoryPayload +): Promise { + const changes: EditChange[] = []; + const itemNames = await entityNameMap(client, 'items', [after.cookwareItemId, after.mainMaterialItemId]); + pushChange(changes, 'Name', before.name, after.name); + pushTranslationChanges(changes, before.translations, after.translations, ['name', 'effect']); + pushChange(changes, 'Cookware', before.cookware.name, itemNames.get(after.cookwareItemId)); + pushChange(changes, 'Main material', before.mainMaterial.name, itemNames.get(after.mainMaterialItemId)); + pushChange(changes, 'Total material quantity', String(before.totalMaterialQuantity), String(after.totalMaterialQuantity)); + pushChange(changes, 'Effect', before.effect, after.effect); + return changes; +} + +async function dishEditChanges(client: DbClient, before: DishChangeSource, after: DishPayload): Promise { + const changes: EditChange[] = []; + const categoryNames = await entityNameMap(client, 'dish_categories', [after.categoryId]); + const itemNames = await entityNameMap(client, 'items', [after.itemId, ...after.secondaryMaterialItemIds]); + const flavorNames = await entityNameMap(client, 'dish_flavors', [after.flavorId]); + const skillNames = await entityNameMap(client, 'skills', after.pokemonSkillId ? [after.pokemonSkillId] : []); + + pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId)); + pushChange(changes, 'Dish item', before.item.name, itemNames.get(after.itemId)); + pushChange(changes, 'Flavor', before.flavor.name, flavorNames.get(after.flavorId)); + pushTranslationChanges(changes, before.translations, after.translations, ['mosslaxEffect']); + pushChange(changes, 'Secondary materials', namedListValue(before.secondaryMaterials), namesFromIds(after.secondaryMaterialItemIds, itemNames)); + pushChange(changes, 'Pokemon speciality', before.pokemonSkill?.name, after.pokemonSkillId ? skillNames.get(after.pokemonSkillId) : null); + pushChange(changes, 'Mosslax effect', before.mosslaxEffect, after.mosslaxEffect); + return changes; +} + +export async function createDishCategory(payload: Record, userId: number, locale = defaultLocale) { + const cleanPayload = cleanDishCategoryPayload(payload); + const id = await withTransaction(async (client) => { + const sortOrder = await nextSortOrder(client, 'dish_categories'); + const result = await client.query<{ id: number }>( + ` + INSERT INTO dish_categories ( + name, + cookware_item_id, + main_material_item_id, + total_material_quantity, + effect, + sort_order, + created_by_user_id, + updated_by_user_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + RETURNING id + `, + [ + cleanPayload.name, + cleanPayload.cookwareItemId, + cleanPayload.mainMaterialItemId, + cleanPayload.totalMaterialQuantity, + cleanPayload.effect, + sortOrder, + userId + ] + ); + const categoryId = result.rows[0].id; + await replaceEntityTranslations(client, 'dish-categories', categoryId, cleanPayload.translations, ['name', 'effect']); + await recordEditLog(client, 'dish-categories', categoryId, 'create', userId); + return categoryId; + }); + return getDishCategory(id, locale); +} + +export async function updateDishCategory(id: number, payload: Record, userId: number, locale = defaultLocale) { + const cleanPayload = cleanDishCategoryPayload(payload); + const before = await getDishCategory(id, defaultLocale); + const updated = await withTransaction(async (client) => { + await ensureDishCategoryMaterialSlots(client, id, cleanPayload); + const result = await client.query( + ` + UPDATE dish_categories + SET name = $1, + cookware_item_id = $2, + main_material_item_id = $3, + total_material_quantity = $4, + effect = $5, + updated_by_user_id = $6, + updated_at = now() + WHERE id = $7 + `, + [ + cleanPayload.name, + cleanPayload.cookwareItemId, + cleanPayload.mainMaterialItemId, + cleanPayload.totalMaterialQuantity, + cleanPayload.effect, + userId, + id + ] + ); + if (result.rowCount === 0) { + return false; + } + await replaceEntityTranslations(client, 'dish-categories', id, cleanPayload.translations, ['name', 'effect']); + const changes = before ? await dishCategoryEditChanges(client, before as unknown as DishCategoryChangeSource, cleanPayload) : []; + await recordEditLog(client, 'dish-categories', id, 'update', userId, changes); + return true; + }); + return updated ? getDishCategory(id, locale) : null; +} + +export async function deleteDishCategory(id: number, userId: number) { + return withTransaction(async (client) => { + const childRows = await client.query<{ id: number }>('SELECT id FROM dishes WHERE category_id = $1', [id]); + const result = await client.query<{ id: number }>('DELETE FROM dish_categories WHERE id = $1 RETURNING id', [id]); + if (result.rowCount === 0) { + return false; + } + + await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', ['dish-categories', id]); + const childIds = childRows.rows.map((row) => row.id); + if (childIds.length) { + await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = ANY($2::integer[])', [ + 'dishes', + childIds + ]); + } + await recordEditLog(client, 'dish-categories', id, 'delete', userId); + return true; + }); +} + +export async function createDish(payload: Record, userId: number, locale = defaultLocale) { + const cleanPayload = cleanDishPayload(payload); + const id = await withTransaction(async (client) => { + await ensureDishMaterialSlots(client, cleanPayload); + const sortOrder = await nextSortOrder(client, 'dishes'); + const result = await client.query<{ id: number }>( + ` + INSERT INTO dishes ( + category_id, + item_id, + flavor_id, + secondary_material_1_item_id, + secondary_material_2_item_id, + pokemon_skill_id, + mosslax_effect, + sort_order, + created_by_user_id, + updated_by_user_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9) + RETURNING id + `, + [ + cleanPayload.categoryId, + cleanPayload.itemId, + cleanPayload.flavorId, + cleanPayload.secondaryMaterialItemIds[0] ?? null, + cleanPayload.secondaryMaterialItemIds[1] ?? null, + cleanPayload.pokemonSkillId, + cleanPayload.mosslaxEffect, + sortOrder, + userId + ] + ); + const dishId = result.rows[0].id; + await replaceEntityTranslations(client, 'dishes', dishId, cleanPayload.translations, ['mosslaxEffect']); + await recordEditLog(client, 'dishes', dishId, 'create', userId); + return dishId; + }); + return getDish(id, locale); +} + +export async function updateDish(id: number, payload: Record, userId: number, locale = defaultLocale) { + const cleanPayload = cleanDishPayload(payload); + const before = await getDish(id, defaultLocale); + const updated = await withTransaction(async (client) => { + await ensureDishMaterialSlots(client, cleanPayload); + const result = await client.query( + ` + UPDATE dishes + SET category_id = $1, + item_id = $2, + flavor_id = $3, + secondary_material_1_item_id = $4, + secondary_material_2_item_id = $5, + pokemon_skill_id = $6, + mosslax_effect = $7, + updated_by_user_id = $8, + updated_at = now() + WHERE id = $9 + `, + [ + cleanPayload.categoryId, + cleanPayload.itemId, + cleanPayload.flavorId, + cleanPayload.secondaryMaterialItemIds[0] ?? null, + cleanPayload.secondaryMaterialItemIds[1] ?? null, + cleanPayload.pokemonSkillId, + cleanPayload.mosslaxEffect, + userId, + id + ] + ); + if (result.rowCount === 0) { + return false; + } + await replaceEntityTranslations(client, 'dishes', id, cleanPayload.translations, ['mosslaxEffect']); + const changes = before ? await dishEditChanges(client, before as unknown as DishChangeSource, cleanPayload) : []; + await recordEditLog(client, 'dishes', id, 'update', userId, changes); + return true; + }); + return updated ? getDish(id, locale) : null; +} + +export async function deleteDish(id: number, userId: number) { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>('DELETE FROM dishes WHERE id = $1 RETURNING id', [id]); + if (result.rowCount === 0) { + return false; + } + + await deleteEntityTranslations(client, 'dishes', id); + await recordEditLog(client, 'dishes', id, 'delete', userId); + return true; + }); +} + +export async function reorderDishCategories(payload: Record, userId: number, locale = defaultLocale) { + const ids = cleanIds(payload.ids); + if (ids.length === 0) { + throw validationError('server.validation.selectRecord'); + } + await withTransaction(async (client) => { + await reorderTableRows(client, 'dish_categories', 'dish-categories', ids, userId); + }); + return listDish(locale); +} + +export async function reorderDishes(payload: Record, userId: number, locale = defaultLocale) { + const ids = cleanIds(payload.ids); + if (ids.length === 0) { + throw validationError('server.validation.selectRecord'); + } + await withTransaction(async (client) => { + await reorderTableRows(client, 'dishes', 'dishes', ids, userId); + }); + return listDish(locale); +} + const dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[]; const dataToolMainTables: Record = { pokemon: 'pokemon', diff --git a/backend/src/server.ts b/backend/src/server.ts index d58d814..36b4d8d 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -38,6 +38,8 @@ import { createAncientArtifact, createConfig, createDailyChecklistItem, + createDish, + createDishCategory, createEntityDiscussionComment, createEntityDiscussionReply, createHabitat, @@ -51,6 +53,8 @@ import { deleteConfig, deleteAncientArtifact, deleteDailyChecklistItem, + deleteDish, + deleteDishCategory, deleteEntityDiscussionComment, deleteHabitat, deleteItem, @@ -71,6 +75,7 @@ import { getAncientArtifact, getHabitat, getItem, + listDish, getLifePost, getOptions, getPokemon, @@ -99,6 +104,8 @@ import { reorderConfig, reorderAncientArtifacts, reorderDailyChecklistItems, + reorderDishCategories, + reorderDishes, reorderHabitats, reorderItems, reorderLanguages, @@ -115,6 +122,8 @@ import { updateConfig, updateAncientArtifact, updateDailyChecklistItem, + updateDish, + updateDishCategory, updateHabitat, updateItem, updateLanguage, @@ -1911,6 +1920,72 @@ app.delete('/api/recipes/:id', async (request, reply) => { return deleted ? reply.code(204).send() : notFound(reply, request); }); +app.get('/api/dish', async (request) => listDish(requestLocale(request))); + +app.post('/api/admin/dish/categories', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite'); + return user + ? reply.code(201).send(await createDishCategory(request.body as Record, user.id, requestLocale(request))) + : undefined; +}); + +app.put('/api/admin/dish/categories/order', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite'); + return user ? reorderDishCategories(request.body as Record, user.id, requestLocale(request)) : undefined; +}); + +app.put('/api/admin/dish/categories/:id', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const category = await updateDishCategory(Number(id), request.body as Record, user.id, requestLocale(request)); + return category ? category : notFound(reply, request); +}); + +app.delete('/api/admin/dish/categories/:id', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const deleted = await deleteDishCategory(Number(id), user.id); + return deleted ? reply.code(204).send() : notFound(reply, request); +}); + +app.post('/api/admin/dish/dishes', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.create', 'wikiWrite'); + return user + ? reply.code(201).send(await createDish(request.body as Record, user.id, requestLocale(request))) + : undefined; +}); + +app.put('/api/admin/dish/dishes/order', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.order', 'wikiWrite'); + return user ? reorderDishes(request.body as Record, user.id, requestLocale(request)) : undefined; +}); + +app.put('/api/admin/dish/dishes/:id', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.update', 'wikiWrite'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const dish = await updateDish(Number(id), request.body as Record, user.id, requestLocale(request)); + return dish ? dish : notFound(reply, request); +}); + +app.delete('/api/admin/dish/dishes/:id', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'dish.delete', 'wikiWrite'); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const deleted = await deleteDish(Number(id), user.id); + return deleted ? reply.code(204).send() : notFound(reply, request); +}); + app.post('/api/admin/daily-checklist', async (request, reply) => { const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite'); return user diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 222c59d..65cf589 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -97,7 +97,7 @@ const navItems = computed(() => { }, { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, { label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() }, - { label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() }, + { label: t('nav.dish'), to: '/dish', icon: iconDish }, { label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() }, { label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() }, { label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() }, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index e6d2556..fa2e9f0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -13,6 +13,7 @@ import RecipeDetail from '../views/RecipeDetail.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue'; import LifePostDetail from '../views/LifePostDetail.vue'; import LifeView from '../views/LifeView.vue'; +import DishView from '../views/DishView.vue'; import ProjectUpdatesView from '../views/ProjectUpdatesView.vue'; import LegalView from '../views/LegalView.vue'; import ComingSoonView from '../views/ComingSoonView.vue'; @@ -267,9 +268,8 @@ export const router = createRouter({ { path: '/dish', name: 'dish', - component: ComingSoonView, - props: { page: 'dish' }, - meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dish.title', descriptionKey: 'pages.comingSoon.sections.dish.subtitle', noindex: true }) } + component: DishView, + meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) } }, { path: '/events', diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9f1dad5..3f90e6d 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'; const authTokenKey = 'pokopia_auth_token'; const authChangeEvent = 'pokopia-auth-change'; -export type TranslationField = 'name' | 'title' | 'details' | 'genus'; +export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect'; export type TranslationMap = Record>>; export interface Language { @@ -311,6 +311,37 @@ export interface Recipe extends EditInfo { materials: Array; } +export interface ItemLink extends NamedEntity { + displayId: number; + image?: EntityImage | null; + category?: NamedEntity; +} + +export interface Dish extends EditInfo { + id: number; + flavor: NamedEntity; + mosslaxEffect: string; + baseMosslaxEffect?: string; + translations?: TranslationMap; + category: NamedEntity; + item: ItemLink; + secondaryMaterials: ItemLink[]; + pokemonSkill: Skill | null; +} + +export interface DishCategory extends EditInfo { + id: number; + name: string; + baseName?: string; + effect: string; + baseEffect?: string; + translations?: TranslationMap; + cookware: ItemLink; + mainMaterial: ItemLink; + totalMaterialQuantity: number; + dishes: Dish[]; +} + export interface DailyChecklistItem { id: number; title: string; @@ -552,6 +583,7 @@ export interface Options { maps: NamedEntity[]; lifeCategories: LifeCategory[]; gameVersions: GameVersion[]; + dishFlavors: NamedEntity[]; } export interface AuthUser { @@ -711,7 +743,8 @@ export type ConfigType = | 'acquisition-methods' | 'maps' | 'life-tags' - | 'game-versions'; + | 'game-versions' + | 'dish-flavors'; export interface PokemonPayload { dataId?: number | null; @@ -790,6 +823,25 @@ export interface RecipePayload { materials: Array<{ itemId: number; quantity: number }>; } +export interface DishCategoryPayload { + name: string; + effect: string; + translations?: TranslationMap; + cookwareItemId: number; + mainMaterialItemId: number; + totalMaterialQuantity: number; +} + +export interface DishPayload { + categoryId: number; + itemId: number; + flavorId: number; + secondaryMaterialItemIds: number[]; + pokemonSkillId: number | null; + mosslaxEffect: string; + translations?: TranslationMap; +} + export interface HabitatPayload { name: string; translations?: TranslationMap; @@ -1359,5 +1411,16 @@ export const api = { updateRecipe: (id: string | number, payload: RecipePayload) => sendJson(`/api/recipes/${id}`, 'PUT', payload), deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`), - reorderRecipes: (ids: number[]) => sendJson('/api/admin/recipes/order', 'PUT', { ids }) + reorderRecipes: (ids: number[]) => sendJson('/api/admin/recipes/order', 'PUT', { ids }), + dish: () => getJson('/api/dish'), + createDishCategory: (payload: DishCategoryPayload) => sendJson('/api/admin/dish/categories', 'POST', payload), + updateDishCategory: (id: string | number, payload: DishCategoryPayload) => + sendJson(`/api/admin/dish/categories/${id}`, 'PUT', payload), + deleteDishCategory: (id: string | number) => deleteJson(`/api/admin/dish/categories/${id}`), + reorderDishCategories: (ids: number[]) => sendJson('/api/admin/dish/categories/order', 'PUT', { ids }), + createDish: (payload: DishPayload) => sendJson('/api/admin/dish/dishes', 'POST', payload), + updateDish: (id: string | number, payload: DishPayload) => + sendJson(`/api/admin/dish/dishes/${id}`, 'PUT', payload), + deleteDish: (id: string | number) => deleteJson(`/api/admin/dish/dishes/${id}`), + reorderDishes: (ids: number[]) => sendJson('/api/admin/dish/dishes/order', 'PUT', { ids }) }; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 4af1f23..b3a89fa 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -8824,6 +8824,156 @@ button:disabled, } } +.dish-category-panel { + display: grid; + gap: 24px; +} + +.dish-category-summary { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + gap: 20px; + align-items: start; +} + +.dish-category-summary__content { + display: grid; + gap: 14px; +} + +.dish-category-summary__content h2 { + margin: 0; + font-size: 24px; +} + +.dish-media-link { + width: 112px; + aspect-ratio: 1; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); + box-shadow: var(--shadow-soft); +} + +.dish-media-link img { + width: 82%; + height: 82%; + object-fit: contain; +} + +.dish-media-link--small { + width: 76px; + box-shadow: none; +} + +.dish-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.dish-card { + min-width: 0; + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 14px; + padding: 16px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface); +} + +.dish-card__content { + min-width: 0; + display: grid; + gap: 10px; +} + +.dish-card__title { + color: var(--ink); + font-weight: 900; + line-height: 1.3; +} + +.dish-card__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.dish-card__meta span { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: var(--radius-small); + background: var(--surface-soft); + color: var(--ink-soft); + font-size: 13px; + font-weight: 800; +} + +.dish-category-effect-row { + display: grid; + gap: 6px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.dish-category-effect-row strong { + color: var(--ink-soft); + font-size: 13px; +} + +.dish-form-stack { + display: grid; + gap: 14px; +} + +.dish-form-row { + display: grid; + gap: 14px; +} + +.dish-form-row--3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.dish-form-row--4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +.info-list--compact { + gap: 8px; + font-size: 14px; +} + +@media (max-width: 640px) { + .dish-category-summary, + .dish-card { + grid-template-columns: 1fr; + } + + .dish-form-row, + .dish-form-row--3, + .dish-form-row--4 { + grid-template-columns: 1fr; + } + + .dish-media-link { + width: 96px; + } + + .dish-media-link--small { + width: 72px; + } +} + @media (max-width: 360px) { .brand-lockup--topbar > span { display: none; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 3a933d4..fff43ac 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -16,6 +16,7 @@ import { iconCancel, iconChecklist, iconDelete, + iconDish, iconEdit, iconHabitat, iconItem, @@ -43,6 +44,8 @@ import { type DataToolsBundle, type DataToolsSummary, type DailyChecklistItem, + type Dish, + type DishCategory, type GameVersion, type Habitat, type Item, @@ -80,6 +83,7 @@ type AdminTab = | 'items' | 'ancientArtifacts' | 'recipes' + | 'dish' | 'habitats'; type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; @@ -131,6 +135,7 @@ const adminTabIcons: Record = { items: iconItem, ancientArtifacts: iconArtifact, recipes: iconRecipe, + dish: iconDish, habitats: iconHabitat }; @@ -156,6 +161,7 @@ const adminNavigationGroups = computed(() => { permission: ['ancient-artifacts.order', 'ancient-artifacts.delete'] }, { key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] }, + { key: 'dish', label: t('pages.admin.dishList'), permission: ['dish.create', 'dish.update', 'dish.delete', 'dish.order'] }, { key: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] }, { key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] } ] @@ -197,7 +203,8 @@ const configTypes = computed< { key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'maps', label: t('config.maps') }, { key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true }, - { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true } + { key: 'game-versions', label: t('config.gameVersions'), supportsChangeLog: true }, + { key: 'dish-flavors', label: t('config.dishFlavors') } ]); const activeTab = ref('config'); @@ -212,6 +219,10 @@ const pokemonRows = ref([]); const itemRows = ref([]); const ancientArtifactRows = ref([]); const recipeRows = ref([]); +const dishCategoryRows = ref([]); +const dishItemRows = ref([]); +const dishSkillRows = ref([]); +const dishFlavorRows = ref([]); const habitatRows = ref([]); const wordingRows = ref([]); const aiModerationSettings = ref(null); @@ -231,6 +242,25 @@ const configForm = ref({ changeLog: '' }); const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap }); +const dishCategoryForm = ref({ + id: 0, + name: '', + effect: '', + translations: {} as TranslationMap, + cookwareItemId: '', + mainMaterialItemId: '', + totalMaterialQuantity: 2 +}); +const dishForm = ref({ + id: 0, + categoryId: '', + itemId: '', + flavorId: '', + translations: {} as TranslationMap, + secondaryMaterialItemIds: ['', ''], + pokemonSkillId: '', + mosslaxEffect: '' +}); const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }); const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] }); const aiModerationForm = ref({ @@ -262,6 +292,8 @@ const permissionForm = ref({ id: 0, key: '', name: '', description: '', category const editingLanguageCode = ref(''); const configModalOpen = ref(false); const checklistModalOpen = ref(false); +const dishCategoryModalOpen = ref(false); +const dishModalOpen = ref(false); const languageModalOpen = ref(false); const wordingModalOpen = ref(false); const userRoleModalOpen = ref(false); @@ -320,6 +352,13 @@ const configModalTitle = computed(() => configForm.value.id ? t('pages.admin.editConfig', { name: selectedConfig.value.label }) : t('pages.admin.newConfig', { name: selectedConfig.value.label }) ); const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask'))); +const dishCategoryModalTitle = computed(() => + dishCategoryForm.value.id ? t('pages.dish.editCategory') : t('pages.dish.newCategory') +); +const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish'))); +const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes)); +const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null); +const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2); const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage'))); const wordingModalTitle = computed(() => t('pages.admin.editWording')); const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole'))); @@ -414,6 +453,10 @@ const ancientArtifactKey = (item: AncientArtifact) => item.id; const ancientArtifactLabel = (item: AncientArtifact) => `#${item.displayId} ${item.name}`; const recipeKey = (item: Recipe) => item.id; const recipeLabel = (item: Recipe) => item.name; +const dishCategoryKey = (item: DishCategory) => item.id; +const dishCategoryLabel = (item: DishCategory) => item.name; +const dishKey = (item: Dish) => item.id; +const dishLabel = (item: Dish) => `#${item.item.displayId} ${item.item.name}`; const habitatKey = (item: Habitat) => item.id; const habitatLabel = (item: Habitat) => item.name; @@ -525,6 +568,31 @@ function resetChecklistForm() { checklistForm.value = { id: 0, title: '', translations: {} }; } +function resetDishCategoryForm() { + dishCategoryForm.value = { + id: 0, + name: '', + effect: '', + translations: {}, + cookwareItemId: '', + mainMaterialItemId: '', + totalMaterialQuantity: 2 + }; +} + +function resetDishForm() { + dishForm.value = { + id: 0, + categoryId: dishCategoryRows.value[0] ? String(dishCategoryRows.value[0].id) : '', + itemId: '', + flavorId: '', + translations: {}, + secondaryMaterialItemIds: ['', ''], + pokemonSkillId: '', + mosslaxEffect: '' + }; +} + function resetLanguageForm() { languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }; editingLanguageCode.value = ''; @@ -621,6 +689,53 @@ function editChecklistItem(item: DailyChecklistItem) { checklistModalOpen.value = true; } +function openNewDishCategory() { + resetDishCategoryForm(); + dishCategoryModalOpen.value = true; +} + +function closeDishCategoryModal() { + dishCategoryModalOpen.value = false; + resetDishCategoryForm(); +} + +function editDishCategory(item: DishCategory) { + dishCategoryForm.value = { + id: item.id, + name: item.baseName ?? item.name, + effect: item.baseEffect ?? item.effect, + translations: item.translations ?? {}, + cookwareItemId: String(item.cookware.id), + mainMaterialItemId: String(item.mainMaterial.id), + totalMaterialQuantity: item.totalMaterialQuantity + }; + dishCategoryModalOpen.value = true; +} + +function openNewDish() { + resetDishForm(); + dishModalOpen.value = true; +} + +function closeDishModal() { + dishModalOpen.value = false; + resetDishForm(); +} + +function editDish(item: Dish) { + dishForm.value = { + id: item.id, + categoryId: String(item.category.id), + itemId: String(item.item.id), + flavorId: String(item.flavor.id), + translations: item.translations ?? {}, + secondaryMaterialItemIds: [String(item.secondaryMaterials[0]?.id ?? ''), String(item.secondaryMaterials[1]?.id ?? '')], + pokemonSkillId: String(item.pokemonSkill?.id ?? ''), + mosslaxEffect: item.baseMosslaxEffect ?? item.mosslaxEffect + }; + dishModalOpen.value = true; +} + function openNewLanguage() { resetLanguageForm(); languageModalOpen.value = true; @@ -786,6 +901,21 @@ function previewRecipeOrder(rows: Recipe[]) { recipeRows.value = rows; } +function previewDishCategoryOrder(rows: DishCategory[]) { + dishCategoryRows.value = rows; +} + +function previewDishOrder(rows: Dish[]) { + const rowsById = new Map(rows.map((row) => [row.id, row])); + const orderById = new Map(rows.map((row, index) => [row.id, index])); + dishCategoryRows.value = dishCategoryRows.value.map((category) => ({ + ...category, + dishes: category.dishes + .map((dish) => rowsById.get(dish.id) ?? dish) + .sort((a, b) => (orderById.get(a.id) ?? 0) - (orderById.get(b.id) ?? 0)) + })); +} + function previewHabitatOrder(rows: Habitat[]) { habitatRows.value = rows; } @@ -875,6 +1005,30 @@ async function persistRecipeOrder(nextRows: Recipe[], fallbackRows: Recipe[]) { }); } +async function persistDishCategoryOrder(nextRows: DishCategory[], fallbackRows: DishCategory[]) { + dishCategoryRows.value = nextRows; + await run(async () => { + try { + dishCategoryRows.value = await api.reorderDishCategories(nextRows.map((item) => item.id)); + } catch (error) { + dishCategoryRows.value = fallbackRows; + throw error; + } + }); +} + +async function persistDishOrder(nextRows: Dish[], fallbackRows: Dish[]) { + previewDishOrder(nextRows); + await run(async () => { + try { + dishCategoryRows.value = await api.reorderDishes(nextRows.map((item) => item.id)); + } catch (error) { + previewDishOrder(fallbackRows); + throw error; + } + }); +} + async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) { habitatRows.value = nextRows; await run(async () => { @@ -935,6 +1089,59 @@ async function saveChecklistItem() { }); } +function dishCategoryPayloadForSave() { + return { + name: dishCategoryForm.value.name, + effect: dishCategoryForm.value.effect, + translations: dishCategoryForm.value.translations, + cookwareItemId: Number(dishCategoryForm.value.cookwareItemId), + mainMaterialItemId: Number(dishCategoryForm.value.mainMaterialItemId), + totalMaterialQuantity: Number(dishCategoryForm.value.totalMaterialQuantity) + }; +} + +function dishPayloadForSave() { + const secondaryMaterialItemIds = dishForm.value.secondaryMaterialItemIds + .map((itemId) => Number(itemId)) + .filter((itemId) => Number.isInteger(itemId) && itemId > 0); + + return { + categoryId: Number(dishForm.value.categoryId), + itemId: Number(dishForm.value.itemId), + flavorId: Number(dishForm.value.flavorId), + translations: dishForm.value.translations, + secondaryMaterialItemIds: dishAllowsSecondSecondaryMaterial.value ? secondaryMaterialItemIds : secondaryMaterialItemIds.slice(0, 1), + pokemonSkillId: dishForm.value.pokemonSkillId ? Number(dishForm.value.pokemonSkillId) : null, + mosslaxEffect: dishForm.value.mosslaxEffect + }; +} + +async function saveDishCategory() { + await run(async () => { + const payload = dishCategoryPayloadForSave(); + if (dishCategoryForm.value.id) { + await api.updateDishCategory(dishCategoryForm.value.id, payload); + } else { + await api.createDishCategory(payload); + } + await loadDishAdmin(); + closeDishCategoryModal(); + }); +} + +async function saveDish() { + await run(async () => { + const payload = dishPayloadForSave(); + if (dishForm.value.id) { + await api.updateDish(dishForm.value.id, payload); + } else { + await api.createDish(payload); + } + await loadDishAdmin(); + closeDishModal(); + }); +} + async function saveLanguage() { await run(async () => { const payload = { @@ -978,6 +1185,18 @@ async function loadRecipes() { recipeRows.value = await api.recipes(); } +async function loadDishAdmin() { + await loadLanguages(); + const [dishCategories, items, options] = await Promise.all([api.dish(), api.items({}), api.options()]); + dishCategoryRows.value = dishCategories; + dishItemRows.value = items; + dishSkillRows.value = options.skills; + dishFlavorRows.value = options.dishFlavors; + if (!dishForm.value.id && !dishForm.value.categoryId) { + resetDishForm(); + } +} + async function loadHabitats() { habitatRows.value = await api.habitats(); } @@ -1153,6 +1372,7 @@ async function loadCurrentTab(showSkeleton = false) { if (activeTab.value === 'items') await loadItems(); if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts(); if (activeTab.value === 'recipes') await loadRecipes(); + if (activeTab.value === 'dish') await loadDishAdmin(); if (activeTab.value === 'habitats') await loadHabitats(); } finally { if (showSkeleton) { @@ -1253,6 +1473,26 @@ async function removeRecipe(id: number) { }); } +async function removeDishCategory(id: number) { + await run(async () => { + await api.deleteDishCategory(id); + if (dishCategoryForm.value.id === id) { + closeDishCategoryModal(); + } + await loadDishAdmin(); + }); +} + +async function removeDish(id: number) { + await run(async () => { + await api.deleteDish(id); + if (dishForm.value.id === id) { + closeDishModal(); + } + await loadDishAdmin(); + }); +} + async function removeHabitat(id: number) { await run(async () => { await api.deleteHabitat(id); @@ -2088,6 +2328,84 @@ onMounted(() => {

{{ t('common.noRecords') }}

+
+
+

{{ t('pages.admin.dishList') }}

+ + + + +
+ +

{{ t('pages.dish.categories') }}

+ + + +

{{ t('common.noRecords') }}

+ +

{{ t('pages.dish.dishes') }}

+ + + +

{{ t('common.noRecords') }}

+
+

{{ t('pages.admin.habitatList') }}

{ + + + + + +