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