feat(dish): add dish management and public view

Add database schema, permissions, and API endpoints for dishes
Implement frontend views and admin management for dish data
This commit is contained in:
2026-05-04 21:00:23 +08:00
parent 2ff2519647
commit 2220d5d595
12 changed files with 2147 additions and 25 deletions

View File

@@ -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() },

View File

@@ -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',

View File

@@ -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 })
};

View File

@@ -8824,6 +8824,156 @@ button:disabled,
}
}
.dish-category-panel {
display: grid;
gap: 24px;
}
.dish-category-summary {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 20px;
align-items: start;
}
.dish-category-summary__content {
display: grid;
gap: 14px;
}
.dish-category-summary__content h2 {
margin: 0;
font-size: 24px;
}
.dish-media-link {
width: 112px;
aspect-ratio: 1;
display: grid;
place-items: center;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
box-shadow: var(--shadow-soft);
}
.dish-media-link img {
width: 82%;
height: 82%;
object-fit: contain;
}
.dish-media-link--small {
width: 76px;
box-shadow: none;
}
.dish-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.dish-card {
min-width: 0;
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 14px;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
}
.dish-card__content {
min-width: 0;
display: grid;
gap: 10px;
}
.dish-card__title {
color: var(--ink);
font-weight: 900;
line-height: 1.3;
}
.dish-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dish-card__meta span {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 4px 8px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
color: var(--ink-soft);
font-size: 13px;
font-weight: 800;
}
.dish-category-effect-row {
display: grid;
gap: 6px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.dish-category-effect-row strong {
color: var(--ink-soft);
font-size: 13px;
}
.dish-form-stack {
display: grid;
gap: 14px;
}
.dish-form-row {
display: grid;
gap: 14px;
}
.dish-form-row--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dish-form-row--4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.info-list--compact {
gap: 8px;
font-size: 14px;
}
@media (max-width: 640px) {
.dish-category-summary,
.dish-card {
grid-template-columns: 1fr;
}
.dish-form-row,
.dish-form-row--3,
.dish-form-row--4 {
grid-template-columns: 1fr;
}
.dish-media-link {
width: 96px;
}
.dish-media-link--small {
width: 72px;
}
}
@media (max-width: 360px) {
.brand-lockup--topbar > span {
display: none;

View File

@@ -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">

View File

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