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
This commit is contained in:
2026-05-04 21:00:23 +08:00
parent 2ff2519647
commit 2220d5d595
12 changed files with 2147 additions and 25 deletions

View File

@@ -65,11 +65,16 @@
- 每日 CheckList Task - 每日 CheckList Task
- Life Category - Life Category
- Game Version - Game Version
- Dish Category
- Dish Flavor
- Dish
- 支持翻译的字段: - 支持翻译的字段:
- `name` - `name`
- `title` - `title`
- `details`Pokemon、物品和 Ancient Artifacts 的介绍 / 说明 - `details`Pokemon、物品和 Ancient Artifacts 的介绍 / 说明
- `genus`:仅 Pokemon Genus 使用 - `genus`:仅 Pokemon Genus 使用
- `effect`Dish Category 的吃后效果
- `mosslaxEffect`Dish 给 Mosslax 吃之后的效果
- 实体仍保留基础 `name``title``details``genus` 字段,默认语言内容以基础字段为准。 - 实体仍保留基础 `name``title``details``genus` 字段,默认语言内容以基础字段为准。
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。 - API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。 - 编辑表单必须避免本地化 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、编辑表单、管理入口或排序能力 以下前台公开入口当前仅展示“正在开发中”占位页,不提供数据模型、后端 API、编辑表单、管理入口或排序能力
- Automation未来用于分享自动化基地亦称工厂创建方案、材料产出、所需 Pokemon、生产顺序和共同喜好物品。 - Automation未来用于分享自动化基地亦称工厂创建方案、材料产出、所需 Pokemon、生产顺序和共同喜好物品。
- Dish
- Events - Events
- Actions游戏内快捷动作例如挥手、跳舞等。 - Actions游戏内快捷动作例如挥手、跳舞等。
- Dream Island - Dream Island
@@ -1014,6 +1055,7 @@ API 暴露边界:
- `GET /api/ancient-artifacts/:id` - `GET /api/ancient-artifacts/:id`
- `GET /api/recipes` - `GET /api/recipes`
- `GET /api/recipes/:id` - `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`:支持 `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/following`:需要登录;分页读取当前用户已 Follow 用户发布的 Life Post 动态,支持与 Life Feed 相同的 `cursor` / `limit`、搜索、Category、语言、Game Version、Rateable 和排序筛选。
- `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。 - `GET /api/life-posts/:id`:读取单条 Life Post 详情,遵守软删除和审核可见性规则。

View File

@@ -35,12 +35,15 @@ CREATE TABLE IF NOT EXISTS entity_translations (
'habitats', 'habitats',
'daily-checklist-items', 'daily-checklist-items',
'life-tags', 'life-tags',
'game-versions' 'game-versions',
'dish-categories',
'dish-flavors',
'dishes'
) )
), ),
entity_id integer NOT NULL, entity_id integer NOT NULL,
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE, 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, value text NOT NULL,
PRIMARY KEY (entity_type, entity_id, locale, field_name) PRIMARY KEY (entity_type, entity_id, locale, field_name)
); );
@@ -68,10 +71,21 @@ ALTER TABLE entity_translations
'habitats', 'habitats',
'daily-checklist-items', 'daily-checklist-items',
'life-tags', '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 ( CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE, email text NOT NULL UNIQUE,
@@ -291,6 +305,10 @@ VALUES
('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true), ('recipes.update', 'Update recipes', 'Edit recipe records.', 'Recipes', true),
('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true), ('recipes.delete', 'Delete recipes', 'Delete recipe records.', 'Recipes', true),
('recipes.order', 'Order recipes', 'Reorder 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.create', 'Create Life posts', 'Create Life posts.', 'Life', true),
('life.posts.update', 'Update own Life posts', 'Edit own 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), ('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.update',
'recipes.delete', 'recipes.delete',
'recipes.order', 'recipes.order',
'dish.create',
'dish.update',
'dish.delete',
'dish.order',
'life.posts.create', 'life.posts.create',
'life.posts.update', 'life.posts.update',
'life.posts.delete', 'life.posts.delete',
@@ -459,6 +481,9 @@ JOIN permissions p ON p.key = ANY (ARRAY[
'recipes.create', 'recipes.create',
'recipes.update', 'recipes.update',
'recipes.order', 'recipes.order',
'dish.create',
'dish.update',
'dish.order',
'life.posts.create', 'life.posts.create',
'life.posts.update', 'life.posts.update',
'life.posts.delete', 'life.posts.delete',
@@ -505,6 +530,29 @@ JOIN permissions p ON p.key = ANY (ARRAY[
WHERE r.key = 'editor' WHERE r.key = 'editor'
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.key = ANY (ARRAY[
'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) INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id SELECT r.id, p.id
FROM roles r FROM roles r
@@ -1024,6 +1072,112 @@ CREATE TABLE IF NOT EXISTS recipe_materials (
PRIMARY KEY (recipe_id, item_id) 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 ( CREATE TABLE IF NOT EXISTS maps (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, 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_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 ancient_artifacts_display_order_idx ON ancient_artifacts(display_id, sort_order, id);
CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id); CREATE INDEX IF NOT EXISTS recipes_sort_order_idx ON recipes(sort_order, id);
CREATE INDEX IF NOT EXISTS 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 maps_sort_order_idx ON maps(sort_order, id);
CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id); CREATE INDEX IF NOT EXISTS habitats_sort_order_idx ON habitats(sort_order, id);

View File

@@ -63,7 +63,7 @@ type GlobalSearchResults = {
groups: GlobalSearchGroup[]; groups: GlobalSearchGroup[];
}; };
type TranslationField = 'name' | 'title' | 'details' | 'genus'; type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>; type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
type EntityType = type EntityType =
| 'pokemon' | 'pokemon'
@@ -80,7 +80,10 @@ type EntityType =
| 'habitats' | 'habitats'
| 'daily-checklist-items' | 'daily-checklist-items'
| 'life-tags' | 'life-tags'
| 'game-versions'; | 'game-versions'
| 'dish-categories'
| 'dish-flavors'
| 'dishes';
type ConfigType = type ConfigType =
| 'pokemon-types' | 'pokemon-types'
@@ -90,7 +93,8 @@ type ConfigType =
| 'acquisition-methods' | 'acquisition-methods'
| 'maps' | 'maps'
| 'life-tags' | 'life-tags'
| 'game-versions'; | 'game-versions'
| 'dish-flavors';
type ConfigDefinition = { type ConfigDefinition = {
table: string; table: string;
@@ -232,6 +236,25 @@ type RecipePayload = {
materials: IdQuantity[]; 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 = { type DailyChecklistPayload = {
title: string; title: string;
translations: TranslationInput; translations: TranslationInput;
@@ -550,6 +573,25 @@ type RecipeChangeSource = {
acquisition_methods: Array<{ name: string }>; acquisition_methods: Array<{ name: string }>;
materials: Array<{ name: string; quantity: number }>; 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 = { type DailyChecklistChangeSource = {
title: string; title: string;
} & TranslationChangeSource; } & TranslationChangeSource;
@@ -625,7 +667,8 @@ const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
maps: { table: 'maps', entityType: 'maps' }, maps: { table: 'maps', entityType: 'maps' },
'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true }, 'life-tags': { table: 'life_tags', entityType: 'life-tags', hasDefault: true, hasRateable: true },
'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<SortableContentType, SortableContentDefinition> = { const sortableContentDefinitions: Record<SortableContentType, SortableContentDefinition> = {
@@ -2008,7 +2051,9 @@ const translationChangeLabels: Record<TranslationField, string> = {
name: 'Name', name: 'Name',
title: 'Title', title: 'Title',
details: 'Details', details: 'Details',
genus: 'Genus' genus: 'Genus',
effect: 'Effect',
mosslaxEffect: 'Mosslax effect'
}; };
function translationFieldValue( function translationFieldValue(
@@ -2414,7 +2459,8 @@ export async function getOptions(locale = defaultLocale) {
acquisitionMethods, acquisitionMethods,
maps, maps,
lifeCategories, lifeCategories,
gameVersions gameVersions,
dishFlavors
] = await Promise.all([ ] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale), optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale), skillOptions(locale),
@@ -2423,7 +2469,8 @@ export async function getOptions(locale = defaultLocale) {
optionSelect('acquisition_methods', 'acquisition-methods', locale), optionSelect('acquisition_methods', 'acquisition-methods', locale),
optionSelect('maps', 'maps', locale), optionSelect('maps', 'maps', locale),
lifeCategoryOptions(locale), lifeCategoryOptions(locale),
gameVersionOptions(locale) gameVersionOptions(locale),
optionSelect('dish_flavors', 'dish-flavors', locale)
]); ]);
return { return {
@@ -2438,7 +2485,8 @@ export async function getOptions(locale = defaultLocale) {
itemTags: favoriteThings, itemTags: favoriteThings,
maps, maps,
lifeCategories, 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<string, unknown>): 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<string, unknown>): 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<void> {
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<void> {
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<EditChange[]> {
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<EditChange[]> {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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 dataToolScopes = ['pokemon', 'habitats', 'items', 'artifacts', 'recipes', 'checklist'] as const satisfies readonly DataToolScope[];
const dataToolMainTables: Record<DataToolScope, string> = { const dataToolMainTables: Record<DataToolScope, string> = {
pokemon: 'pokemon', pokemon: 'pokemon',

View File

@@ -38,6 +38,8 @@ import {
createAncientArtifact, createAncientArtifact,
createConfig, createConfig,
createDailyChecklistItem, createDailyChecklistItem,
createDish,
createDishCategory,
createEntityDiscussionComment, createEntityDiscussionComment,
createEntityDiscussionReply, createEntityDiscussionReply,
createHabitat, createHabitat,
@@ -51,6 +53,8 @@ import {
deleteConfig, deleteConfig,
deleteAncientArtifact, deleteAncientArtifact,
deleteDailyChecklistItem, deleteDailyChecklistItem,
deleteDish,
deleteDishCategory,
deleteEntityDiscussionComment, deleteEntityDiscussionComment,
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
@@ -71,6 +75,7 @@ import {
getAncientArtifact, getAncientArtifact,
getHabitat, getHabitat,
getItem, getItem,
listDish,
getLifePost, getLifePost,
getOptions, getOptions,
getPokemon, getPokemon,
@@ -99,6 +104,8 @@ import {
reorderConfig, reorderConfig,
reorderAncientArtifacts, reorderAncientArtifacts,
reorderDailyChecklistItems, reorderDailyChecklistItems,
reorderDishCategories,
reorderDishes,
reorderHabitats, reorderHabitats,
reorderItems, reorderItems,
reorderLanguages, reorderLanguages,
@@ -115,6 +122,8 @@ import {
updateConfig, updateConfig,
updateAncientArtifact, updateAncientArtifact,
updateDailyChecklistItem, updateDailyChecklistItem,
updateDish,
updateDishCategory,
updateHabitat, updateHabitat,
updateItem, updateItem,
updateLanguage, updateLanguage,
@@ -1911,6 +1920,72 @@ app.delete('/api/recipes/:id', async (request, reply) => {
return deleted ? reply.code(204).send() : notFound(reply, request); return deleted ? reply.code(204).send() : notFound(reply, request);
}); });
app.get('/api/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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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) => { app.post('/api/admin/daily-checklist', async (request, reply) => {
const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite'); const user = await requirePermissionWithRateLimits(request, reply, 'checklist.create', 'wikiWrite');
return user return user

View File

@@ -97,7 +97,7 @@ const navItems = computed<NavItem[]>(() => {
}, },
{ label: t('nav.recipes'), to: '/recipes', icon: iconRecipe }, { label: t('nav.recipes'), to: '/recipes', icon: iconRecipe },
{ label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() }, { label: t('nav.automation'), to: '/automation', icon: iconAutomation, badge: inDevBadge() },
{ label: t('nav.dish'), to: '/dish', icon: iconDish, badge: inDevBadge() }, { label: t('nav.dish'), to: '/dish', icon: iconDish },
{ label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() }, { label: t('nav.events'), to: '/events', icon: iconEvent, badge: inDevBadge() },
{ label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() }, { label: t('nav.actions'), to: '/actions', icon: iconAction, badge: inDevBadge() },
{ label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() }, { label: t('nav.dreamIsland'), to: '/dream-island', icon: iconDreamIsland, badge: inDevBadge() },

View File

@@ -13,6 +13,7 @@ import RecipeDetail from '../views/RecipeDetail.vue';
import DailyChecklistView from '../views/DailyChecklistView.vue'; import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifePostDetail from '../views/LifePostDetail.vue'; import LifePostDetail from '../views/LifePostDetail.vue';
import LifeView from '../views/LifeView.vue'; import LifeView from '../views/LifeView.vue';
import DishView from '../views/DishView.vue';
import ProjectUpdatesView from '../views/ProjectUpdatesView.vue'; import ProjectUpdatesView from '../views/ProjectUpdatesView.vue';
import LegalView from '../views/LegalView.vue'; import LegalView from '../views/LegalView.vue';
import ComingSoonView from '../views/ComingSoonView.vue'; import ComingSoonView from '../views/ComingSoonView.vue';
@@ -267,9 +268,8 @@ export const router = createRouter({
{ {
path: '/dish', path: '/dish',
name: 'dish', name: 'dish',
component: ComingSoonView, component: DishView,
props: { page: 'dish' }, meta: { seo: seo({ titleKey: 'pages.dish.title', descriptionKey: 'pages.dish.subtitle' }) }
meta: { seo: seo({ titleKey: 'pages.comingSoon.sections.dish.title', descriptionKey: 'pages.comingSoon.sections.dish.subtitle', noindex: true }) }
}, },
{ {
path: '/events', path: '/events',

View File

@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token'; const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change'; 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<string, Partial<Record<TranslationField, string>>>; export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
export interface Language { export interface Language {
@@ -311,6 +311,37 @@ export interface Recipe extends EditInfo {
materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>; materials: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
} }
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 { export interface DailyChecklistItem {
id: number; id: number;
title: string; title: string;
@@ -552,6 +583,7 @@ export interface Options {
maps: NamedEntity[]; maps: NamedEntity[];
lifeCategories: LifeCategory[]; lifeCategories: LifeCategory[];
gameVersions: GameVersion[]; gameVersions: GameVersion[];
dishFlavors: NamedEntity[];
} }
export interface AuthUser { export interface AuthUser {
@@ -711,7 +743,8 @@ export type ConfigType =
| 'acquisition-methods' | 'acquisition-methods'
| 'maps' | 'maps'
| 'life-tags' | 'life-tags'
| 'game-versions'; | 'game-versions'
| 'dish-flavors';
export interface PokemonPayload { export interface PokemonPayload {
dataId?: number | null; dataId?: number | null;
@@ -790,6 +823,25 @@ export interface RecipePayload {
materials: Array<{ itemId: number; quantity: number }>; 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 { export interface HabitatPayload {
name: string; name: string;
translations?: TranslationMap; translations?: TranslationMap;
@@ -1359,5 +1411,16 @@ export const api = {
updateRecipe: (id: string | number, payload: RecipePayload) => updateRecipe: (id: string | number, payload: RecipePayload) =>
sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload), sendJson<RecipeDetail>(`/api/recipes/${id}`, 'PUT', payload),
deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`), deleteRecipe: (id: string | number) => deleteJson(`/api/recipes/${id}`),
reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids }) reorderRecipes: (ids: number[]) => sendJson<Recipe[]>('/api/admin/recipes/order', 'PUT', { ids }),
dish: () => getJson<DishCategory[]>('/api/dish'),
createDishCategory: (payload: DishCategoryPayload) => sendJson<DishCategory>('/api/admin/dish/categories', 'POST', payload),
updateDishCategory: (id: string | number, payload: DishCategoryPayload) =>
sendJson<DishCategory>(`/api/admin/dish/categories/${id}`, 'PUT', payload),
deleteDishCategory: (id: string | number) => deleteJson(`/api/admin/dish/categories/${id}`),
reorderDishCategories: (ids: number[]) => sendJson<DishCategory[]>('/api/admin/dish/categories/order', 'PUT', { ids }),
createDish: (payload: DishPayload) => sendJson<Dish>('/api/admin/dish/dishes', 'POST', payload),
updateDish: (id: string | number, payload: DishPayload) =>
sendJson<Dish>(`/api/admin/dish/dishes/${id}`, 'PUT', payload),
deleteDish: (id: string | number) => deleteJson(`/api/admin/dish/dishes/${id}`),
reorderDishes: (ids: number[]) => sendJson<DishCategory[]>('/api/admin/dish/dishes/order', 'PUT', { ids })
}; };

View File

@@ -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) { @media (max-width: 360px) {
.brand-lockup--topbar > span { .brand-lockup--topbar > span {
display: none; display: none;

View File

@@ -16,6 +16,7 @@ import {
iconCancel, iconCancel,
iconChecklist, iconChecklist,
iconDelete, iconDelete,
iconDish,
iconEdit, iconEdit,
iconHabitat, iconHabitat,
iconItem, iconItem,
@@ -43,6 +44,8 @@ import {
type DataToolsBundle, type DataToolsBundle,
type DataToolsSummary, type DataToolsSummary,
type DailyChecklistItem, type DailyChecklistItem,
type Dish,
type DishCategory,
type GameVersion, type GameVersion,
type Habitat, type Habitat,
type Item, type Item,
@@ -80,6 +83,7 @@ type AdminTab =
| 'items' | 'items'
| 'ancientArtifacts' | 'ancientArtifacts'
| 'recipes' | 'recipes'
| 'dish'
| 'habitats'; | 'habitats';
type AdminGroup = 'content' | 'configuration' | 'localization' | 'access'; type AdminGroup = 'content' | 'configuration' | 'localization' | 'access';
type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] }; type AdminNavItem = { key: AdminTab; label: string; permission: string | string[] };
@@ -131,6 +135,7 @@ const adminTabIcons: Record<AdminTab, AppIcon> = {
items: iconItem, items: iconItem,
ancientArtifacts: iconArtifact, ancientArtifacts: iconArtifact,
recipes: iconRecipe, recipes: iconRecipe,
dish: iconDish,
habitats: iconHabitat habitats: iconHabitat
}; };
@@ -156,6 +161,7 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
permission: ['ancient-artifacts.order', 'ancient-artifacts.delete'] permission: ['ancient-artifacts.order', 'ancient-artifacts.delete']
}, },
{ key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] }, { key: 'recipes', label: t('pages.admin.recipeList'), permission: ['recipes.order', 'recipes.delete'] },
{ key: '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: 'habitats', label: t('pages.admin.habitatList'), permission: ['habitats.order', 'habitats.delete'] },
{ key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] } { key: 'dataTools', label: t('pages.admin.dataTools'), permission: ['admin.data.export', 'admin.data.import'] }
] ]
@@ -197,7 +203,8 @@ const configTypes = computed<
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') }, { key: 'maps', label: t('config.maps') },
{ key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true }, { key: 'life-tags', label: t('config.lifeCategories'), supportsDefault: true, supportsRateable: true },
{ 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<AdminTab>('config'); const activeTab = ref<AdminTab>('config');
@@ -212,6 +219,10 @@ const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]); const itemRows = ref<Item[]>([]);
const ancientArtifactRows = ref<AncientArtifact[]>([]); const ancientArtifactRows = ref<AncientArtifact[]>([]);
const recipeRows = ref<Recipe[]>([]); const recipeRows = ref<Recipe[]>([]);
const dishCategoryRows = ref<DishCategory[]>([]);
const dishItemRows = ref<Item[]>([]);
const dishSkillRows = ref<Skill[]>([]);
const dishFlavorRows = ref<NamedEntity[]>([]);
const habitatRows = ref<Habitat[]>([]); const habitatRows = ref<Habitat[]>([]);
const wordingRows = ref<SystemWording[]>([]); const wordingRows = ref<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null); const aiModerationSettings = ref<AiModerationSettings | null>(null);
@@ -231,6 +242,25 @@ const configForm = ref({
changeLog: '' changeLog: ''
}); });
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap }); 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 languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] }); const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
const aiModerationForm = ref({ const aiModerationForm = ref({
@@ -262,6 +292,8 @@ const permissionForm = ref({ id: 0, key: '', name: '', description: '', category
const editingLanguageCode = ref(''); const editingLanguageCode = ref('');
const configModalOpen = ref(false); const configModalOpen = ref(false);
const checklistModalOpen = ref(false); const checklistModalOpen = ref(false);
const dishCategoryModalOpen = ref(false);
const dishModalOpen = ref(false);
const languageModalOpen = ref(false); const languageModalOpen = ref(false);
const wordingModalOpen = ref(false); const wordingModalOpen = ref(false);
const userRoleModalOpen = 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 }) 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 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 languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
const wordingModalTitle = computed(() => t('pages.admin.editWording')); const wordingModalTitle = computed(() => t('pages.admin.editWording'));
const roleModalTitle = computed(() => (roleForm.value.id ? t('pages.admin.editRole') : t('pages.admin.newRole'))); 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 ancientArtifactLabel = (item: AncientArtifact) => `#${item.displayId} ${item.name}`;
const recipeKey = (item: Recipe) => item.id; const recipeKey = (item: Recipe) => item.id;
const recipeLabel = (item: Recipe) => item.name; const recipeLabel = (item: Recipe) => item.name;
const 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 habitatKey = (item: Habitat) => item.id;
const habitatLabel = (item: Habitat) => item.name; const habitatLabel = (item: Habitat) => item.name;
@@ -525,6 +568,31 @@ function resetChecklistForm() {
checklistForm.value = { id: 0, title: '', translations: {} }; 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() { function resetLanguageForm() {
languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 }; languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 };
editingLanguageCode.value = ''; editingLanguageCode.value = '';
@@ -621,6 +689,53 @@ function editChecklistItem(item: DailyChecklistItem) {
checklistModalOpen.value = true; 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() { function openNewLanguage() {
resetLanguageForm(); resetLanguageForm();
languageModalOpen.value = true; languageModalOpen.value = true;
@@ -786,6 +901,21 @@ function previewRecipeOrder(rows: Recipe[]) {
recipeRows.value = rows; 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[]) { function previewHabitatOrder(rows: Habitat[]) {
habitatRows.value = rows; 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[]) { async function persistHabitatOrder(nextRows: Habitat[], fallbackRows: Habitat[]) {
habitatRows.value = nextRows; habitatRows.value = nextRows;
await run(async () => { 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() { async function saveLanguage() {
await run(async () => { await run(async () => {
const payload = { const payload = {
@@ -978,6 +1185,18 @@ async function loadRecipes() {
recipeRows.value = await api.recipes(); 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() { async function loadHabitats() {
habitatRows.value = await api.habitats(); habitatRows.value = await api.habitats();
} }
@@ -1153,6 +1372,7 @@ async function loadCurrentTab(showSkeleton = false) {
if (activeTab.value === 'items') await loadItems(); if (activeTab.value === 'items') await loadItems();
if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts(); if (activeTab.value === 'ancientArtifacts') await loadAncientArtifacts();
if (activeTab.value === 'recipes') await loadRecipes(); if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'dish') await loadDishAdmin();
if (activeTab.value === 'habitats') await loadHabitats(); if (activeTab.value === 'habitats') await loadHabitats();
} finally { } finally {
if (showSkeleton) { 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) { async function removeHabitat(id: number) {
await run(async () => { await run(async () => {
await api.deleteHabitat(id); await api.deleteHabitat(id);
@@ -2088,6 +2328,84 @@ onMounted(() => {
<p v-else class="meta-line">{{ t('common.noRecords') }}</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'dish'" class="detail-section">
<div class="detail-section__header">
<h2>{{ t('pages.admin.dishList') }}</h2>
<span class="row-actions">
<button v-if="can('dish.create')" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewDishCategory">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newCategory') }}
</button>
<button v-if="can('dish.create')" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy || !dishCategoryRows.length" @click="openNewDish">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newDish') }}
</button>
</span>
</div>
<h3 class="section-subtitle">{{ t('pages.dish.categories') }}</h3>
<ReorderableList
v-if="dishCategoryRows.length"
:items="dishCategoryRows"
:item-key="dishCategoryKey"
:item-label="dishCategoryLabel"
list-key-prefix="dish-categories"
:disabled="busy || !can('dish.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewDishCategoryOrder"
@cancel="previewDishCategoryOrder"
@reorder="persistDishCategoryOrder"
>
<template #default="{ item }">
<span class="reorderable-row-title">{{ item.name }}</span>
<span class="meta-line">{{ item.cookware.name }} / {{ item.mainMaterial.name }} / {{ item.totalMaterialQuantity }}</span>
<span class="row-actions">
<button v-if="can('dish.update')" type="button" :disabled="busy" @click="editDishCategory(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('dish.delete')" type="button" :disabled="busy" @click="removeDishCategory(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
<h3 class="section-subtitle">{{ t('pages.dish.dishes') }}</h3>
<ReorderableList
v-if="dishRows.length"
:items="dishRows"
:item-key="dishKey"
:item-label="dishLabel"
list-key-prefix="dishes"
:disabled="busy || !can('dish.order')"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewDishOrder"
@cancel="previewDishOrder"
@reorder="persistDishOrder"
>
<template #default="{ item }">
<RouterLink :to="`/items/${item.item.id}`">#{{ item.item.displayId }} {{ item.item.name }}</RouterLink>
<span class="meta-line">{{ item.category.name }} / {{ item.flavor.name }}</span>
<span class="row-actions">
<button v-if="can('dish.update')" type="button" :disabled="busy" @click="editDish(item)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="can('dish.delete')" type="button" :disabled="busy" @click="removeDish(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</span>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
<h2>{{ t('pages.admin.habitatList') }}</h2> <h2>{{ t('pages.admin.habitatList') }}</h2>
<ReorderableList <ReorderableList
@@ -2324,6 +2642,131 @@ onMounted(() => {
</template> </template>
</Modal> </Modal>
<Modal v-if="dishCategoryModalOpen" :title="dishCategoryModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishCategoryModal">
<form id="admin-dish-category-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDishCategory">
<div class="dish-form-row dish-form-row--4">
<TranslationFields
id-prefix="dish-category-name"
v-model:base-value="dishCategoryForm.name"
v-model:translations="dishCategoryForm.translations"
field="name"
:label="t('common.name')"
:languages="languageRows"
required
/>
<div class="field">
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
<select id="dish-category-cookware" v-model="dishCategoryForm.cookwareItemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`cookware-${item.id}`" :value="String(item.id)">#{{ item.displayId }} {{ item.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
<input id="dish-category-total-material-quantity" v-model.number="dishCategoryForm.totalMaterialQuantity" type="number" min="2" required />
</div>
<div class="field">
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
<select id="dish-category-main-material" v-model="dishCategoryForm.mainMaterialItemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`category-main-material-${item.id}`" :value="String(item.id)">#{{ item.displayId }} {{ item.name }}</option>
</select>
</div>
</div>
<TranslationFields
id-prefix="dish-category-effect"
v-model:base-value="dishCategoryForm.effect"
v-model:translations="dishCategoryForm.translations"
field="effect"
:label="t('pages.dish.effect')"
:languages="languageRows"
required
/>
</form>
<template #footer>
<button type="submit" form="admin-dish-category-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishCategoryModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="dishModalOpen" :title="dishModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishModal">
<form id="admin-dish-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDish">
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-category">{{ t('pages.dish.category') }}</label>
<select id="dish-category" v-model="dishForm.categoryId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="category in dishCategoryRows" :key="`dish-category-option-${category.id}`" :value="String(category.id)">{{ category.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
<select id="dish-item" v-model="dishForm.itemId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-item-${item.id}`" :value="String(item.id)">#{{ item.displayId }} {{ item.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
<select id="dish-flavor" v-model="dishForm.flavorId" required>
<option value="">{{ t('common.none') }}</option>
<option v-for="flavor in dishFlavorRows" :key="`dish-flavor-${flavor.id}`" :value="String(flavor.id)">{{ flavor.name }}</option>
</select>
</div>
</div>
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
<select id="dish-secondary-material-1" v-model="dishForm.secondaryMaterialItemIds[0]">
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-secondary-material-1-${item.id}`" :value="String(item.id)">#{{ item.displayId }} {{ item.name }}</option>
</select>
</div>
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
<select id="dish-secondary-material-2" v-model="dishForm.secondaryMaterialItemIds[1]">
<option value="">{{ t('common.none') }}</option>
<option v-for="item in dishItemRows" :key="`dish-secondary-material-2-${item.id}`" :value="String(item.id)">#{{ item.displayId }} {{ item.name }}</option>
</select>
</div>
<div class="field">
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
<select id="dish-pokemon-skill" v-model="dishForm.pokemonSkillId">
<option value="">{{ t('common.none') }}</option>
<option v-for="skill in dishSkillRows" :key="`dish-skill-${skill.id}`" :value="String(skill.id)">{{ skill.name }}</option>
</select>
</div>
</div>
<TranslationFields
id-prefix="dish-mosslax-effect"
v-model:base-value="dishForm.mosslaxEffect"
v-model:translations="dishForm.translations"
field="mosslaxEffect"
:label="t('pages.dish.mosslaxEffect')"
:languages="languageRows"
required
/>
</form>
<template #footer>
<button type="submit" form="admin-dish-form" class="link-button" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="configModalOpen" :title="configModalTitle" :close-label="t('common.close')" @close="closeConfigModal"> <Modal v-if="configModalOpen" :title="configModalTitle" :close-label="t('common.close')" @close="closeConfigModal">
<form id="admin-config-form" class="modal-edit-form" @submit.prevent="saveConfig"> <form id="admin-config-form" class="modal-edit-form" @submit.prevent="saveConfig">
<div class="field"> <div class="field">

View File

@@ -0,0 +1,609 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EntityChips from '../components/EntityChips.vue';
import Modal from '../components/Modal.vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconAdd, iconCancel, iconDelete, iconDish, iconEdit, iconItem, iconSave } from '../icons';
import {
api,
getAuthToken,
type AuthUser,
type Dish,
type DishCategory,
type Item,
type ItemLink,
type Language,
type NamedEntity,
type Skill,
type TranslationMap
} from '../services/api';
const { t } = useI18n();
const categories = ref<DishCategory[]>([]);
const activeCategoryId = ref('');
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const currentUser = ref<AuthUser | null>(null);
const languages = ref<Language[]>([]);
const items = ref<Item[]>([]);
const skills = ref<Skill[]>([]);
const dishFlavors = ref<NamedEntity[]>([]);
const dishCategoryModalOpen = ref(false);
const dishModalOpen = ref(false);
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 categoryTabs = computed<TabOption[]>(() =>
categories.value.map((category) => ({ value: String(category.id), label: category.name }))
);
const activeCategory = computed(() =>
categories.value.find((category) => String(category.id) === activeCategoryId.value) ?? categories.value[0] ?? null
);
const canCreateDish = computed(() => currentUser.value?.permissions.includes('dish.create') === true);
const canUpdateDish = computed(() => currentUser.value?.permissions.includes('dish.update') === true);
const canDeleteDish = computed(() => currentUser.value?.permissions.includes('dish.delete') === true);
const selectedDishFormCategory = computed(() => categories.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
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 itemSelectOptions = computed<TagsSelectOption[]>(() =>
items.value.map((item) => ({ id: item.id, name: item.name, label: `#${item.displayId} ${item.name}` }))
);
const optionalItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...itemSelectOptions.value]);
const categorySelectOptions = computed<TagsSelectOption[]>(() => categories.value.map((category) => ({ id: category.id, name: category.name })));
const flavorSelectOptions = computed<TagsSelectOption[]>(() => dishFlavors.value.map((flavor) => ({ id: flavor.id, name: flavor.name })));
const optionalSkillSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...skills.value]);
const dishCategoryFormValid = computed(
() =>
dishCategoryForm.value.name.trim() !== '' &&
dishCategoryForm.value.effect.trim() !== '' &&
dishCategoryForm.value.cookwareItemId !== '' &&
dishCategoryForm.value.mainMaterialItemId !== '' &&
Number(dishCategoryForm.value.totalMaterialQuantity) >= 2
);
const dishFormValid = computed(
() =>
dishForm.value.categoryId !== '' &&
dishForm.value.itemId !== '' &&
dishForm.value.flavorId !== '' &&
dishForm.value.mosslaxEffect.trim() !== ''
);
function itemImage(item: ItemLink) {
return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : null;
}
function resetDishCategoryForm() {
dishCategoryForm.value = {
id: 0,
name: '',
effect: '',
translations: {},
cookwareItemId: '',
mainMaterialItemId: '',
totalMaterialQuantity: 2
};
}
function resetDishForm() {
dishForm.value = {
id: 0,
categoryId: activeCategory.value ? String(activeCategory.value.id) : categories.value[0] ? String(categories.value[0].id) : '',
itemId: '',
flavorId: '',
translations: {},
secondaryMaterialItemIds: ['', ''],
pokemonSkillId: '',
mosslaxEffect: ''
};
}
function openNewDishCategory() {
resetDishCategoryForm();
dishCategoryModalOpen.value = true;
}
function editDishCategory(category: DishCategory) {
dishCategoryForm.value = {
id: category.id,
name: category.baseName ?? category.name,
effect: category.baseEffect ?? category.effect,
translations: category.translations ?? {},
cookwareItemId: String(category.cookware.id),
mainMaterialItemId: String(category.mainMaterial.id),
totalMaterialQuantity: category.totalMaterialQuantity
};
dishCategoryModalOpen.value = true;
}
function closeDishCategoryModal() {
dishCategoryModalOpen.value = false;
resetDishCategoryForm();
}
function openNewDish() {
resetDishForm();
dishModalOpen.value = true;
}
function editDish(dish: Dish) {
dishForm.value = {
id: dish.id,
categoryId: String(dish.category.id),
itemId: String(dish.item.id),
flavorId: String(dish.flavor.id),
translations: dish.translations ?? {},
secondaryMaterialItemIds: [String(dish.secondaryMaterials[0]?.id ?? ''), String(dish.secondaryMaterials[1]?.id ?? '')],
pokemonSkillId: String(dish.pokemonSkill?.id ?? ''),
mosslaxEffect: dish.baseMosslaxEffect ?? dish.mosslaxEffect
};
dishModalOpen.value = true;
}
function closeDishModal() {
dishModalOpen.value = false;
resetDishForm();
}
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
};
}
function errorText(error: unknown) {
return error instanceof Error && error.message ? error.message : t('errors.operationFailed');
}
async function run(action: () => Promise<void>) {
busy.value = true;
message.value = '';
try {
await action();
} catch (error) {
message.value = errorText(error);
} finally {
busy.value = false;
}
}
async function loadDish(showSkeleton = false) {
if (showSkeleton) {
loading.value = true;
}
categories.value = await api.dish();
activeCategoryId.value = categories.value[0] ? String(categories.value[0].id) : '';
loading.value = false;
}
async function saveDishCategory() {
if (!dishCategoryFormValid.value) {
return;
}
await run(async () => {
const payload = dishCategoryPayloadForSave();
if (dishCategoryForm.value.id) {
await api.updateDishCategory(dishCategoryForm.value.id, payload);
} else {
await api.createDishCategory(payload);
}
await loadDish();
closeDishCategoryModal();
});
}
async function saveDish() {
if (!dishFormValid.value) {
return;
}
await run(async () => {
const payload = dishPayloadForSave();
if (dishForm.value.id) {
await api.updateDish(dishForm.value.id, payload);
} else {
await api.createDish(payload);
}
await loadDish();
closeDishModal();
});
}
async function removeDishCategory(id: number) {
await run(async () => {
await api.deleteDishCategory(id);
await loadDish();
});
}
async function removeDish(id: number) {
await run(async () => {
await api.deleteDish(id);
await loadDish();
});
}
async function loadEditorOptions() {
const [nextLanguages, nextItems, nextOptions] = await Promise.all([api.languages(), api.items({}), api.options()]);
languages.value = nextLanguages;
items.value = nextItems;
skills.value = nextOptions.skills;
dishFlavors.value = nextOptions.dishFlavors;
}
async function loadPage() {
loading.value = true;
if (getAuthToken()) {
try {
currentUser.value = (await api.me()).user;
} catch {
currentUser.value = null;
}
}
await Promise.all([loadDish(), loadEditorOptions()]);
}
watch(categories, (nextCategories) => {
if (!nextCategories.some((category) => String(category.id) === activeCategoryId.value)) {
activeCategoryId.value = nextCategories[0] ? String(nextCategories[0].id) : '';
}
});
watch(
() => dishForm.value.categoryId,
() => {
if (!dishAllowsSecondSecondaryMaterial.value) {
dishForm.value.secondaryMaterialItemIds[1] = '';
}
}
);
onMounted(loadPage);
</script>
<template>
<section class="page-stack dish-page">
<PageHeader :title="t('pages.dish.title')" :subtitle="t('pages.dish.subtitle')">
<template #kicker>{{ t('pages.dish.kicker') }}</template>
<template #actions>
<button v-if="canCreateDish" type="button" class="ui-button ui-button--primary ui-button--small" :disabled="busy" @click="openNewDishCategory">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newCategory') }}
</button>
<button v-if="canCreateDish" type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy || !categories.length" @click="openNewDish">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.dish.newDish') }}
</button>
</template>
</PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<div v-if="loading" class="tabs tabs--component" aria-hidden="true">
<div class="tab-list tab-list--skeleton">
<Skeleton v-for="width in ['86px', '112px', '96px']" :key="width" variant="box" :width="width" height="42px" class="skeleton-tab" />
</div>
</div>
<Tabs v-else-if="categoryTabs.length" id="dish-category-tabs" v-model="activeCategoryId" :tabs="categoryTabs" :label="t('pages.dish.category')" />
<section v-if="loading" class="detail-section dish-category-panel" aria-busy="true" :aria-label="t('pages.dish.loading')">
<div class="dish-category-summary">
<Skeleton variant="box" width="96px" height="96px" class="skeleton-entity-mark" />
<div class="dish-category-summary__content">
<Skeleton width="180px" height="28px" />
<Skeleton width="100%" />
<Skeleton width="72%" />
</div>
</div>
<div class="dish-grid">
<article v-for="index in 4" :key="`dish-skeleton-${index}`" class="dish-card">
<Skeleton variant="box" width="72px" height="72px" class="skeleton-entity-mark" />
<div class="dish-card__content">
<Skeleton width="140px" height="24px" />
<Skeleton width="92px" />
<Skeleton width="100%" />
</div>
</article>
</div>
</section>
<section v-else-if="activeCategory" class="detail-section dish-category-panel">
<div class="dish-category-summary">
<RouterLink class="dish-media-link" :to="`/items/${activeCategory.cookware.id}`">
<img
v-if="itemImage(activeCategory.cookware)"
:src="itemImage(activeCategory.cookware)?.src"
:alt="itemImage(activeCategory.cookware)?.alt"
loading="lazy"
/>
<Icon v-else :icon="iconDish" class="entity-card__icon" aria-hidden="true" />
</RouterLink>
<div class="dish-category-summary__content">
<h2>{{ activeCategory.name }}</h2>
<dl class="info-list">
<div>
<dt>{{ t('pages.dish.cookware') }}</dt>
<dd>
<RouterLink :to="`/items/${activeCategory.cookware.id}`">#{{ activeCategory.cookware.displayId }} {{ activeCategory.cookware.name }}</RouterLink>
</dd>
</div>
<div>
<dt>{{ t('pages.dish.totalMaterialQuantity') }}</dt>
<dd>{{ activeCategory.totalMaterialQuantity }}</dd>
</div>
<div>
<dt>{{ t('pages.dish.mainMaterial') }}</dt>
<dd>
<RouterLink :to="`/items/${activeCategory.mainMaterial.id}`">#{{ activeCategory.mainMaterial.displayId }} {{ activeCategory.mainMaterial.name }}</RouterLink>
</dd>
</div>
</dl>
<div class="dish-category-effect-row">
<strong>{{ t('pages.dish.effect') }}</strong>
<span>{{ activeCategory.effect }}</span>
</div>
<div v-if="canUpdateDish || canDeleteDish" class="row-actions">
<button v-if="canUpdateDish" type="button" :disabled="busy" @click="editDishCategory(activeCategory)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="canDeleteDish" type="button" :disabled="busy" @click="removeDishCategory(activeCategory.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
<div class="dish-grid">
<article v-for="dish in activeCategory.dishes" :key="dish.id" class="dish-card">
<RouterLink class="dish-media-link dish-media-link--small" :to="`/items/${dish.item.id}`">
<img v-if="dish.item.image" :src="dish.item.image.url" :alt="t('media.imageAlt', { name: dish.item.name })" loading="lazy" />
<Icon v-else :icon="iconItem" class="entity-card__icon" aria-hidden="true" />
</RouterLink>
<div class="dish-card__content">
<RouterLink class="dish-card__title" :to="`/items/${dish.item.id}`">#{{ dish.item.displayId }} {{ dish.item.name }}</RouterLink>
<div class="dish-card__meta">
<span>{{ dish.flavor.name }}</span>
<span v-if="dish.pokemonSkill">{{ dish.pokemonSkill.name }}</span>
</div>
<dl class="info-list info-list--compact">
<div>
<dt>{{ t('pages.dish.secondaryMaterials') }}</dt>
<dd>
<EntityChips v-if="dish.secondaryMaterials.length" :items="dish.secondaryMaterials" />
<span v-else>{{ t('common.none') }}</span>
</dd>
</div>
<div>
<dt>{{ t('pages.dish.mosslaxEffect') }}</dt>
<dd>{{ dish.mosslaxEffect }}</dd>
</div>
</dl>
<div v-if="canUpdateDish || canDeleteDish" class="row-actions">
<button v-if="canUpdateDish" type="button" :disabled="busy" @click="editDish(dish)">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('common.edit') }}
</button>
<button v-if="canDeleteDish" type="button" :disabled="busy" @click="removeDish(dish.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />
{{ t('common.delete') }}
</button>
</div>
</div>
</article>
</div>
<p v-if="!activeCategory.dishes.length" class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else class="detail-section">
<p class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<Modal v-if="dishCategoryModalOpen" :title="dishCategoryModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishCategoryModal">
<form id="dish-category-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDishCategory">
<div class="dish-form-row dish-form-row--4">
<TranslationFields
id-prefix="dish-category-name"
v-model:base-value="dishCategoryForm.name"
v-model:translations="dishCategoryForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field">
<label for="dish-category-cookware">{{ t('pages.dish.cookware') }}</label>
<TagsSelect
id="dish-category-cookware"
v-model="dishCategoryForm.cookwareItemId"
:options="itemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-category-total-material-quantity">{{ t('pages.dish.totalMaterialQuantity') }}</label>
<input id="dish-category-total-material-quantity" v-model.number="dishCategoryForm.totalMaterialQuantity" type="number" min="2" required />
</div>
<div class="field">
<label for="dish-category-main-material">{{ t('pages.dish.mainMaterial') }}</label>
<TagsSelect
id="dish-category-main-material"
v-model="dishCategoryForm.mainMaterialItemId"
:options="itemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
</div>
<TranslationFields
id-prefix="dish-category-effect"
v-model:base-value="dishCategoryForm.effect"
v-model:translations="dishCategoryForm.translations"
field="effect"
:label="t('pages.dish.effect')"
:languages="languages"
required
/>
</form>
<template #footer>
<button type="submit" form="dish-category-form" class="link-button" :disabled="busy || !dishCategoryFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishCategoryModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<Modal v-if="dishModalOpen" :title="dishModalTitle" :close-label="t('common.close')" size="wide" @close="closeDishModal">
<form id="dish-form" class="modal-edit-form dish-form-stack" @submit.prevent="saveDish">
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-category">{{ t('pages.dish.category') }}</label>
<TagsSelect
id="dish-category"
v-model="dishForm.categoryId"
:options="categorySelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.category')"
/>
</div>
<div class="field">
<label for="dish-item">{{ t('pages.dish.dishItem') }}</label>
<TagsSelect
id="dish-item"
v-model="dishForm.itemId"
:options="itemSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-flavor">{{ t('pages.dish.flavor') }}</label>
<TagsSelect
id="dish-flavor"
v-model="dishForm.flavorId"
:options="flavorSelectOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.dish.flavor')"
/>
</div>
</div>
<div class="dish-form-row dish-form-row--3">
<div class="field">
<label for="dish-secondary-material-1">{{ t('pages.dish.secondaryMaterial') }}</label>
<TagsSelect
id="dish-secondary-material-1"
v-model="dishForm.secondaryMaterialItemIds[0]"
:options="optionalItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div v-if="dishAllowsSecondSecondaryMaterial" class="field">
<label for="dish-secondary-material-2">{{ t('pages.dish.secondSecondaryMaterial') }}</label>
<TagsSelect
id="dish-secondary-material-2"
v-model="dishForm.secondaryMaterialItemIds[1]"
:options="optionalItemSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="dish-pokemon-skill">{{ t('pages.dish.pokemonSkill') }}</label>
<TagsSelect
id="dish-pokemon-skill"
v-model="dishForm.pokemonSkillId"
:options="optionalSkillSelectOptions"
:multiple="false"
:placeholder="t('common.none')"
:search-placeholder="t('pages.dish.pokemonSkill')"
/>
</div>
</div>
<TranslationFields
id-prefix="dish-mosslax-effect"
v-model:base-value="dishForm.mosslaxEffect"
v-model:translations="dishForm.translations"
field="mosslaxEffect"
:label="t('pages.dish.mosslaxEffect')"
:languages="languages"
required
/>
</form>
<template #footer>
<button type="submit" form="dish-form" class="link-button" :disabled="busy || !dishFormValid">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="busy" @click="closeDishModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
</section>
</template>

View File

@@ -12,6 +12,7 @@ const sitemapPaths = [
'/event-items', '/event-items',
'/ancient-artifacts', '/ancient-artifacts',
'/recipes', '/recipes',
'/dish',
'/checklist', '/checklist',
'/life' '/life'
]; ];
@@ -36,7 +37,6 @@ const robotsDisallowPaths = [
'/recipes/new', '/recipes/new',
'/recipes/*/edit', '/recipes/*/edit',
'/automation', '/automation',
'/dish',
'/events', '/events',
'/actions', '/actions',
'/dream-island', '/dream-island',

View File

@@ -269,7 +269,7 @@ export const systemWordingMessages = {
}, },
dish: { dish: {
title: 'Dish', title: 'Dish',
description: 'Cooked dish and food discovery records are being prepared.' description: 'Browse cooked dishes by cookware, ingredients, flavor, and Mosslax effects.'
}, },
events: { events: {
title: 'Events', title: 'Events',
@@ -755,6 +755,30 @@ export const systemWordingMessages = {
materials: 'Materials', materials: 'Materials',
addMaterial: 'Add material' addMaterial: 'Add material'
}, },
dish: {
kicker: 'Dish',
title: 'Dish',
subtitle: 'Browse cooked dishes by category, cookware, ingredients, flavor, and Mosslax effects.',
loading: 'Loading Dish records',
category: 'Category',
categories: 'Categories',
dishes: 'Dishes',
cookware: 'Cookware',
effect: 'Effect',
totalMaterialQuantity: 'Total material count',
dishItem: 'Dish item',
flavor: 'Flavor',
mainMaterial: 'Main material',
secondaryMaterial: 'Secondary material',
secondaryMaterials: 'Secondary materials',
secondSecondaryMaterial: 'Second secondary material',
pokemonSkill: 'Pokemon speciality',
mosslaxEffect: 'Mosslax effect',
newCategory: 'New category',
editCategory: 'Edit category',
newDish: 'New dish',
editDish: 'Edit dish'
},
comingSoon: { comingSoon: {
status: 'In development', status: 'In development',
heading: 'This wiki section is being prepared.', heading: 'This wiki section is being prepared.',
@@ -987,6 +1011,7 @@ export const systemWordingMessages = {
itemList: 'Item list', itemList: 'Item list',
ancientArtifactList: 'Ancient Artifact list', ancientArtifactList: 'Ancient Artifact list',
recipeList: 'Recipe list', recipeList: 'Recipe list',
dishList: 'Dish list',
habitatList: 'Habitat list', habitatList: 'Habitat list',
dataTools: 'Data tools', dataTools: 'Data tools',
dataToolRefresh: 'Refresh', dataToolRefresh: 'Refresh',
@@ -1101,7 +1126,8 @@ export const systemWordingMessages = {
acquisitionMethods: 'Acquisition methods', acquisitionMethods: 'Acquisition methods',
maps: 'Maps', maps: 'Maps',
lifeCategories: 'Life categories', lifeCategories: 'Life categories',
gameVersions: 'Game versions' gameVersions: 'Game versions',
dishFlavors: 'Dish flavors'
}, },
appearance: { appearance: {
time: 'Time', time: 'Time',
@@ -1598,7 +1624,7 @@ export const systemWordingMessages = {
}, },
dish: { dish: {
title: 'Dish', title: 'Dish',
description: '料理和食物发现记录正在准备中。' description: '按厨具、材料、口味和苔藓卡比兽效果浏览料理。'
}, },
events: { events: {
title: 'Events', title: 'Events',
@@ -2064,6 +2090,30 @@ export const systemWordingMessages = {
materials: '需要材料', materials: '需要材料',
addMaterial: '添加材料' addMaterial: '添加材料'
}, },
dish: {
kicker: 'Dish',
title: '料理',
subtitle: '按分类、厨具、材料、口味和苔藓卡比兽效果浏览料理。',
loading: '正在加载料理记录',
category: '分类',
categories: '分类',
dishes: '菜肴',
cookware: '厨具',
effect: '吃后效果',
totalMaterialQuantity: '总数所需材料数量',
dishItem: '菜肴物品',
flavor: '口味',
mainMaterial: '主材料',
secondaryMaterial: '副材料',
secondaryMaterials: '副材料',
secondSecondaryMaterial: '第二副材料',
pokemonSkill: 'Pokemon 特长',
mosslaxEffect: 'Mosslax 效果',
newCategory: '新增分类',
editCategory: '编辑分类',
newDish: '新增菜肴',
editDish: '编辑菜肴'
},
comingSoon: { comingSoon: {
status: '正在开发中', status: '正在开发中',
heading: '这个 Wiki 分区正在准备中。', heading: '这个 Wiki 分区正在准备中。',
@@ -2296,6 +2346,7 @@ export const systemWordingMessages = {
itemList: '物品列表', itemList: '物品列表',
ancientArtifactList: 'Ancient Artifact 列表', ancientArtifactList: 'Ancient Artifact 列表',
recipeList: '材料单列表', recipeList: '材料单列表',
dishList: '料理列表',
habitatList: '栖息地列表', habitatList: '栖息地列表',
dataTools: '数据工具', dataTools: '数据工具',
dataToolRefresh: '刷新', dataToolRefresh: '刷新',
@@ -2410,7 +2461,8 @@ export const systemWordingMessages = {
acquisitionMethods: '入手方式', acquisitionMethods: '入手方式',
maps: '地图', maps: '地图',
lifeCategories: 'Life Categories', lifeCategories: 'Life Categories',
gameVersions: '游戏版本' gameVersions: '游戏版本',
dishFlavors: '料理味道'
}, },
appearance: { appearance: {
time: '时段', time: '时段',