459 lines
13 KiB
Vue
459 lines
13 KiB
Vue
<template>
|
|
<div class="container">
|
|
<div class="fields">
|
|
<date-picker
|
|
:date="meal.suggestedDate ?? new Date()"
|
|
@date-selected="selectDate"
|
|
/>
|
|
<div class="members-list">
|
|
Cooked by
|
|
<member-list
|
|
:people="meal.chefs"
|
|
@remove="(p) => removePerson('chefs', p)"
|
|
@add="(p) => addPerson('chefs', p)"
|
|
/>
|
|
for
|
|
<member-list
|
|
:people="meal.consumers"
|
|
@remove="(p) => removePerson('consumers', p)"
|
|
@add="(p) => addPerson('consumers', p)"
|
|
/>, with
|
|
<member-list
|
|
:people="meal.cleanup"
|
|
@remove="(p) => removePerson('cleanup', p)"
|
|
@add="(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-update-line="updateIngredientLine"
|
|
@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 { toMealEdit } from '@/router/links'
|
|
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 { Ingredient, Meal, MealRecipe } from '@/domain/types'
|
|
import { listMembers } from '@/api/households'
|
|
import { selectMyMember } from '@/domain/members'
|
|
|
|
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 MemberList from './MemberList.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()
|
|
|
|
import type { MemberRef } from '@/domain/types'
|
|
|
|
function addPersonIfNotExists<T extends { id: number }>(list: T[], person: T | 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()
|
|
|
|
// No child refs; parent maintains source of truth for lines
|
|
|
|
type PeopleKey = 'chefs' | 'consumers' | 'cleanup'
|
|
|
|
// Track which ingredient objects have unparsed edits so we only parse what changed
|
|
const dirtyLines = new Map<Ingredient, string>()
|
|
|
|
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 [user, members] = await Promise.all([currentUser(), listMembers()])
|
|
const fallback = Array.isArray(members) && members.length > 0 ? { id: members[0]!.id, displayName: members[0]!.displayName } : null
|
|
const me = selectMyMember(members, user) ?? fallback
|
|
if (me) {
|
|
meal.chefs = [me]
|
|
meal.consumers = [me]
|
|
meal.cleanup = [me]
|
|
}
|
|
}
|
|
})
|
|
|
|
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))
|
|
// Clear dirty status for this row (was parsed and replaced)
|
|
if (dirtyLines.has(ingredient)) dirtyLines.delete(ingredient)
|
|
}
|
|
|
|
function updateIngredientLine(ingredient: Ingredient, newLine: string) {
|
|
// Update the raw line immediately so Save has the latest text
|
|
const idx = meal.extraIngredients.indexOf(ingredient)
|
|
if (idx >= 0) {
|
|
// mutate in place to preserve object identity (used as dirtyLines key)
|
|
const target = meal.extraIngredients[idx]
|
|
if (target) target.line = newLine
|
|
} else {
|
|
// fallback (shouldn't generally happen)
|
|
meal.extraIngredients = meal.extraIngredients.map((i) => (i === ingredient ? { ...i, line: newLine } : i))
|
|
}
|
|
// Mark as dirty to parse later (on save) if needed
|
|
dirtyLines.set(ingredient, newLine)
|
|
}
|
|
|
|
function removePerson(list: PeopleKey, person: MemberRef) {
|
|
meal[list] = meal[list].filter((p) => p.id !== person.id)
|
|
}
|
|
|
|
function addPerson(list: PeopleKey, person: MemberRef) {
|
|
addPersonIfNotExists(meal[list], person)
|
|
}
|
|
|
|
async function selectRecipe(recipe: { id: number | string }) {
|
|
// Refetch to get additional details
|
|
const slug = typeof route.params.householdSlug === 'string' ? route.params.householdSlug : ''
|
|
const r = await getRecipe(slug, recipe.id)
|
|
|
|
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: Ingredient) => !!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() {
|
|
try {
|
|
// Blur any focused input so its change handlers run
|
|
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement) {
|
|
document.activeElement.blur()
|
|
}
|
|
|
|
// 1) Drop any empty-line rows and clear their dirty flags
|
|
const nonEmpty: Ingredient[] = []
|
|
for (const ing of meal.extraIngredients) {
|
|
const line = typeof ing.line === 'string' ? ing.line.trim() : ''
|
|
if (line.length === 0) {
|
|
// also clear dirty if present
|
|
if (dirtyLines.has(ing)) dirtyLines.delete(ing)
|
|
continue
|
|
}
|
|
nonEmpty.push(ing)
|
|
}
|
|
meal.extraIngredients = nonEmpty
|
|
|
|
// 2) Build list of only the dirty lines that still exist in the array
|
|
const dirtyEntries: Array<{ ing: Ingredient; line: string }> = []
|
|
for (const [ing, line] of dirtyLines.entries()) {
|
|
// only consider ingredients still present
|
|
if (meal.extraIngredients.includes(ing)) {
|
|
const t = typeof line === 'string' ? line.trim() : ''
|
|
if (t.length > 0) dirtyEntries.push({ ing, line: t })
|
|
}
|
|
}
|
|
|
|
// 3) Parse only dirty lines
|
|
if (dirtyEntries.length > 0) {
|
|
const lines = dirtyEntries.map((e) => e.line)
|
|
const parsed = await (await import('@/api/sdk')).parseIngredients(lines)
|
|
// Replace corresponding rows by identity
|
|
parsed.forEach((p, idx) => {
|
|
const target = dirtyEntries[idx]?.ing
|
|
if (!target) return
|
|
const i = meal.extraIngredients.indexOf(target)
|
|
if (i >= 0) meal.extraIngredients.splice(i, 1, p)
|
|
// Clear dirty marker for this ingredient object
|
|
dirtyLines.delete(target)
|
|
})
|
|
}
|
|
|
|
// Final safety: drop any zero-quantity items (should be rare post-parse)
|
|
meal.extraIngredients = meal.extraIngredients.filter((i: Ingredient) => (typeof i.quantity === 'number' ? i.quantity > 0 : true))
|
|
|
|
const saved = await saveMeal(toMealInput(meal))
|
|
if (saved && saved.id >= 0) {
|
|
Object.assign(meal, saved)
|
|
router.push(toMealEdit(saved.id))
|
|
showAlert({ heading: 'Meal saved', message: 'Meal saved successfully', type: 'success' })
|
|
return
|
|
}
|
|
|
|
showAlert({
|
|
heading: 'Error saving meal',
|
|
message: 'An unknown error occurred while saving the meal',
|
|
type: 'error',
|
|
})
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : 'Failed to save meal'
|
|
showAlert({ heading: 'Error saving meal', message, type: 'error' })
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
img.icon {
|
|
width: 2em;
|
|
height: 2em;
|
|
}
|
|
|
|
.members-list {
|
|
text-align: left;
|
|
padding: 1ex 2em;
|
|
}
|
|
|
|
.members-list p {
|
|
margin: 0;
|
|
padding-bottom: 1em;
|
|
}
|
|
|
|
.member-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>
|