munch-ease-frontend/src/components/meals/EditMealPage.vue
jableader 211489ca55
Some checks failed
CI / build-test (push) Has been cancelled
pruning (#3)
Reviewed-on: #3
Co-authored-by: jableader <jacobdunk@gmail.com>
Co-committed-by: jableader <jacobdunk@gmail.com>
2025-10-25 02:18:30 +00:00

386 lines
9.6 KiB
Vue

<template>
<div class="container">
<div class="fields">
<date-picker
:date="meal.suggestedDate ?? new Date()"
@date-selected="selectDate"
/>
<div class="persons-list">
Cooked by
<person-list
:people="meal.chefs"
@remove-person="(p) => removePerson('chefs', p)"
@add-person="(p) => addPerson('chefs', p)"
/>
for
<person-list
:people="meal.consumers"
@remove-person="(p) => removePerson('consumers', p)"
@add-person="(p) => addPerson('consumers', p)"
/>, with
<person-list
:people="meal.cleanup"
@remove-person="(p) => removePerson('cleanup', p)"
@add-person="(p) => addPerson('cleanup', p)"
/>
on cleanup.
</div>
</div>
<div class="recipes">
<h2>Recipes</h2>
<ul v-if="meal.recipes.length">
<li
v-for="mealRecipe in meal.recipes"
:key="mealRecipe.recipe?.id ?? mealRecipe.recipeId"
>
<div
v-if="mealRecipe.recipe"
class="saved-recipe"
>
<p class="recipe-card">
<recipe-card :recipe="mealRecipe.recipe" />
</p>
<p class="servings">
<input
v-model="mealRecipe.servings"
type="number"
min="1"
>
<small><em>servings</em></small>
</p>
<input
type="checkbox"
class="show-ingredient-checkbox"
:checked="showIngredient(mealRecipe)"
>
<label
for="show-ingredients"
@click="showIngredient(mealRecipe, !showIngredient(mealRecipe))"
>
<img
class="icon"
:src="showIngredientsIcon"
>
</label>
<button
class="icon-button"
@click="removeRecipe(mealRecipe)"
>
<img
class="icon"
:src="trash"
>
</button>
</div>
<div v-if="showIngredient(mealRecipe)">
<ul>
<li
v-for="ingredient in scaleIngredients(mealRecipe)"
:key="ingredient.id"
class="saved-ingredient"
>
<CompactParsedIngredient :ingredient="ingredient" />
</li>
</ul>
</div>
</li>
</ul>
<div v-else>
<p>Add some recipes using the search box</p>
</div>
<div class="fields">
<recipe-search-box @select-recipe="selectRecipe" />
</div>
</div>
<div class="ingredients">
<h2>Sides & Additional Ingredients</h2>
<editable-ingredients-panel
:ingredients="meal.extraIngredients"
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@on-editing="onEditAdditionalIngredients"
/>
</div>
<button @click="onSaveMeal">
Save
</button>
<p v-if="meal.purchaseDate">
<em>Purchased {{ ago(meal.purchaseDate) }}</em>
</p>
</div>
</template>
<script setup lang="ts">
import { reactive, onBeforeMount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getMeal, saveMeal, getRecipe } from '@/api/sdk'
import { toMealInput } from '@/domain/decoders'
import { currentUser } from '@/api/auth'
import { useAlert } from '@/composables/useAlert'
import { parseRouteId } from '@/router/helpers'
import type { Person, Ingredient, Meal, MealRecipe } from '@/domain/types'
import { ago } from '@/dateformats'
import RecipeSearchBox from '@/components/recipes/RecipeSearchBox.vue'
import RecipeCard from '@/components/recipes/RecipeCard.vue'
import DatePicker from './DatePicker.vue'
import EditableIngredientsPanel from '../ingredients/EditableIngredientsPanel.vue'
import PersonList from './PersonList.vue'
import CompactParsedIngredient from '../ingredients/CompactParsedIngredient.vue'
const showIngredientsIcon = new URL('@/assets/show-ingredients.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
function addPersonIfNotExists(list: Person[], person: Person | null | undefined) {
if (!person) return
if (!list.find((p) => p.id === person.id)) {
list.push(person)
}
}
const route = useRoute()
const router = useRouter()
const { show: showAlert } = useAlert()
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
const meal = reactive<Meal>({
id: -1,
suggestedDate: new Date(),
consumedDate: null,
purchaseDate: null,
recipes: [],
extraIngredients: [],
chefs: [],
consumers: [],
cleanup: [],
})
onBeforeMount(async () => {
const id = parseRouteId(route.params.id)
if (id !== null) {
const loaded = await getMeal(id)
Object.assign(meal, loaded)
} else {
const self = await currentUser()
if (self) {
meal.chefs = [self]
meal.consumers = [self]
meal.cleanup = [self]
}
}
})
function selectDate(date: Date) {
meal.suggestedDate = date
}
function removeRecipe(mealRecipe: MealRecipe) {
if (
confirm(
`Are you sure you want to remove ${mealRecipe.servings} servings of ${mealRecipe.recipe?.name ?? 'this recipe'} from this meal?`
)
) {
meal.recipes = meal.recipes.filter((r) => r !== mealRecipe)
}
}
function addIngredient() {
meal.extraIngredients = [{ id: -1, name: '', line: '', unit: 'Items', quantity: 0, preparation: '', productId: null, recipeId: null, mealId: null, product: null }, ...meal.extraIngredients]
}
function deleteIngredient(ingredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.filter((i) => i !== ingredient)
}
function updateIngredient(ingredient: Ingredient, newIngredient: Ingredient) {
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? newIngredient : i))
}
function removePerson(list: PeopleKey, person: Person) {
meal[list] = meal[list].filter((p) => p.id !== person.id)
}
function addPerson(list: PeopleKey, person: Person) {
addPersonIfNotExists(meal[list], person)
}
async function selectRecipe(recipe: { id: number | string }) {
// Refetch to get additional details
const r = await getRecipe(recipe.id)
if (r.createdBy) {
addPersonIfNotExists(meal.chefs, r.createdBy)
addPersonIfNotExists(meal.consumers, r.createdBy)
if (meal.cleanup.length === 0) {
addPersonIfNotExists(meal.cleanup, r.createdBy)
}
}
meal.recipes.push({ recipe: r, recipeId: r.id, mealId: meal.id, servings: r.serves })
}
async function onEditAdditionalIngredients(editing: boolean) {
if (editing && meal.extraIngredients.length === 0) {
addIngredient()
} else {
meal.extraIngredients = meal.extraIngredients.filter((i) => !!i.line)
}
}
const showMap = reactive<Record<string, boolean>>({})
function showIngredient(mealRecipe: MealRecipe, value?: boolean): boolean {
const index = meal.recipes.indexOf(mealRecipe)
const key = `${mealRecipe.recipe?.id ?? 'unknown'}-${index}`
if (value === undefined) {
return !!showMap[key]
}
showMap[key] = value
return value
}
function scaleIngredients(mealRecipe: MealRecipe) {
const ing = mealRecipe.recipe?.ingredients ?? []
const serves = mealRecipe.recipe?.serves ?? 1
return ing.map((i) => {
return {
...i,
quantity: (i.quantity * mealRecipe.servings) / serves,
}
})
}
async function onSaveMeal() {
const saved = await saveMeal(toMealInput(meal))
if (saved && saved.id >= 0) {
Object.assign(meal, saved)
router.push(`/meals/${saved.id}`)
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
return
}
showAlert({
heading: 'Error saving meal',
message: 'An error occurred while saving the meal',
type: 'error',
})
}
</script>
<style scoped>
img.icon {
width: 2em;
height: 2em;
}
.persons-list {
text-align: left;
padding: 1ex 2em;
}
.persons-list p {
margin: 0;
padding-bottom: 1em;
}
.person-list li {
display: inline-block;
padding-right: 1em;
}
.container {
margin: 1em auto;
}
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
li {
padding-bottom: 0.5vh;
}
.saved-ingredient {
display: flex;
justify-content: space-between;
align-items: center;
}
.saved-recipe {
display: flex;
justify-content: space-between;
align-items: center;
}
.saved-ingredient p {
flex: 1;
margin: 0;
margin-right: 1em;
}
.saved-recipe button:hover {
background: #eee;
}
.saved-recipe .recipe-card {
margin: 0;
}
.recipe-card {
flex: 1;
}
.servings {
display: inline-block;
margin-right: 1em;
}
.servings input {
width: 3em;
border: none;
border-bottom: 1px solid #000;
text-align: center;
font-size: large;
font-style: italic;
}
.icon-button {
background: none;
border: none;
cursor: pointer;
border-radius: 1em;
padding: 0.5em;
}
.icon-button:hover {
/* Invert the colors of the trash icon */
filter: invert(1);
}
.show-ingredient-checkbox {
display: none;
}
.show-ingredient-checkbox+label {
cursor: pointer;
background-color: #fff;
padding: 0.5em;
border-radius: 1em;
}
.show-ingredient-checkbox+label:hover {
background-color: #eee;
}
.show-ingredient-checkbox:checked+label {
filter: invert(1);
}
</style>