meal plan and recipe editor improvements

This commit is contained in:
vabene1111
2024-12-28 12:55:20 +01:00
parent ea1e47e579
commit cde632241b
42 changed files with 358 additions and 70 deletions

View File

@@ -0,0 +1,59 @@
<template>
<v-alert :title="props.title" closable @click:close="closeAlert()" v-if="showAlert">
<template #prepend>
<v-icon icon="$help"></v-icon>
</template>
<p>
{{ props.text}}
<v-btn color="success" class="float-right" v-if="props.actionText != ''" @click="emit('click')">{{ actionText}}</v-btn>
</p>
</v-alert>
</template>
<script setup lang="ts">
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import {MessageType, useMessageStore} from "@/stores/MessageStore";
import {computed} from "vue";
// emit click if action is clicked, actual effect must come from parent component
const emit = defineEmits(['click'])
const props = defineProps({
title: {type: String, required: false,},
text: {type: String, required: true},
// show an action button if any text is given and emit click event if button is pressed
actionText: {type: String, required: false,},
})
/**
* somewhat unique hash of the given text to save which alerts have already been closed
*/
const alertHash = computed(() => {
return props.text.split('').reduce((prevHash, currVal) => (((prevHash << 5) - prevHash) + currVal.charCodeAt(0))|0, 0).toString()
})
/**
* only show the alert if it hasn't been closed on that device before
*/
const showAlert = computed(() => {
return !useUserPreferenceStore().deviceSettings.general_closedHelpAlerts.includes(alertHash.value)
})
/**
* called when alert is closed to save this alert into the list of closed alerts
*/
function closeAlert() {
if (!useUserPreferenceStore().deviceSettings.general_closedHelpAlerts.includes(alertHash.value)) {
useUserPreferenceStore().deviceSettings.general_closedHelpAlerts.push(alertHash.value)
} else {
useMessageStore().addMessage(MessageType.ERROR, 'Trying to close already closed alert', 0, props.text)
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,22 @@
<template>
<span v-if="ingredient.amount && !Number.isNaN(ingredient.amount)">{{$n(ingredient.amount)}}</span>
<span class="ms-1" v-if="ingredient.unit">{{ ingredient.unit.name}}</span>
<span class="ms-1" v-if="ingredient.food">{{ ingredient.food.name}}</span>
</template>
<script setup lang="ts">
import {Ingredient} from "@/openapi";
import {PropType} from "vue";
const props = defineProps({
ingredient: {type: {} as PropType<Ingredient>, required: true}
})
</script>
<style scoped>
</style>

View File

@@ -1,34 +1,38 @@
<template>
<v-row class="h-100">
<v-col>
<!-- TODO add hint about CTRL key while drag/drop -->
<calendar-view
:show-date="calendarDate"
:items="planItems"
class="theme-default"
:item-content-height="calendarItemHeight"
:enable-drag-drop="true"
@dropOnDate="dropCalendarItemOnDate"
:display-period-uom="useUserPreferenceStore().deviceSettings.mealplan_displayPeriod"
:display-period-count="useUserPreferenceStore().deviceSettings.mealplan_displayPeriodCount"
:starting-day-of-week="useUserPreferenceStore().deviceSettings.mealplan_startingDayOfWeek"
:display-week-numbers="useUserPreferenceStore().deviceSettings.mealplan_displayWeekNumbers"
:current-period-label="$t('Today')"
@click-date="(date : Date, calendarItems: [], windowEvent: any) => { newPlanDialogDefaultItem.fromDate = date; newPlanDialogDefaultItem.toDate = date; newPlanDialog = true }">
<template #header="{ headerProps }">
<calendar-view-header :header-props="headerProps" @input="(d:Date) => calendarDate = d"></calendar-view-header>
</template>
<template #item="{ value, weekStartDate, top }">
<meal-plan-calendar-item
:item-height="calendarItemHeight"
:value="value"
:item-top="top"
@onDragStart="currentlyDraggedMealplan = value"
@delete="(arg: MealPlan) => {useMealPlanStore().plans.delete(arg.id)}"
:detailed-items="lgAndUp"
></meal-plan-calendar-item>
</template>
</calendar-view>
<v-col class="pb-0">
<v-card class="h-100" :loading="useMealPlanStore().loading">
<!-- TODO add hint about CTRL key while drag/drop -->
<!-- TODO multi selection? date range selection ? -->
<calendar-view
:show-date="calendarDate"
:items="planItems"
class="theme-default"
:item-content-height="calendarItemHeight"
:enable-drag-drop="true"
@dropOnDate="dropCalendarItemOnDate"
:display-period-uom="useUserPreferenceStore().deviceSettings.mealplan_displayPeriod"
:display-period-count="useUserPreferenceStore().deviceSettings.mealplan_displayPeriodCount"
:starting-day-of-week="useUserPreferenceStore().deviceSettings.mealplan_startingDayOfWeek"
:display-week-numbers="useUserPreferenceStore().deviceSettings.mealplan_displayWeekNumbers"
:current-period-label="$t('Today')"
@click-date="(date : Date, calendarItems: [], windowEvent: any) => { newPlanDialogDefaultItem.fromDate = date; newPlanDialogDefaultItem.toDate = date; newPlanDialog = true }">
<template #header="{ headerProps }">
<calendar-view-header :header-props="headerProps" @input="(d:Date) => calendarDate = d"></calendar-view-header>
</template>
<template #item="{ value, weekStartDate, top }">
<meal-plan-calendar-item
:item-height="calendarItemHeight"
:value="value"
:item-top="top"
@onDragStart="currentlyDraggedMealplan = value"
@delete="(arg: MealPlan) => {useMealPlanStore().plans.delete(arg.id)}"
:detailed-items="lgAndUp"
></meal-plan-calendar-item>
</template>
</calendar-view>
</v-card>
<model-edit-dialog model="MealPlan" v-model="newPlanDialog" :itemDefaults="newPlanDialogDefaultItem"
@create="(arg: any) => useMealPlanStore().plans.set(arg.id, arg)"></model-edit-dialog>
@@ -44,8 +48,8 @@ import "vue-simple-calendar/dist/css/default.css"
import MealPlanCalendarItem from "@/components/display/MealPlanCalendarItem.vue";
import {IMealPlanCalendarItem, IMealPlanNormalizedCalendarItem} from "@/types/MealPlan";
import {computed, onMounted, ref} from "vue";
import {DateTime} from "luxon";
import {computed, onMounted, ref, watch} from "vue";
import {DateTime, Duration} from "luxon";
import {useDisplay} from "vuetify";
import {useMealPlanStore} from "@/stores/MealPlanStore";
import ModelEditDialog from "@/components/dialogs/ModelEditDialog.vue";
@@ -89,22 +93,47 @@ const calendarItemHeight = computed(() => {
}
})
onMounted(() => {
useMealPlanStore().refreshFromAPI() //TODO filter to visible date
/**
* watch calendar date and load entries accordingly
*/
watch(calendarDate, () => {
let daysInPeriod = 7
if (useUserPreferenceStore().deviceSettings.mealplan_displayPeriod == 'month') {
daysInPeriod = 31
} else if (useUserPreferenceStore().deviceSettings.mealplan_displayPeriod == 'year') {
daysInPeriod = 365
}
let days = useUserPreferenceStore().deviceSettings.mealplan_displayPeriodCount * daysInPeriod
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.now().plus({days: days}).toJSDate())
})
onMounted(() => {
// initial load for next 30 days
useMealPlanStore().refreshFromAPI(calendarDate.value, DateTime.now().plus({days: 30}).toJSDate())
})
/**
* handle drop event for calendar items on fields
* @param undefinedItem
* @param targetDate
* @param event
*/
function dropCalendarItemOnDate(undefinedItem: IMealPlanNormalizedCalendarItem, targetDate: Date, event: DragEvent) {
//The item argument (first) is undefined because our custom calendar item cannot manipulate the calendar state so the item is unknown to the calendar (probably fixable by somehow binding state to the item)
if (currentlyDraggedMealplan.value.originalItem.mealPlan.id != undefined) {
let mealPlan = useMealPlanStore().plans.get(currentlyDraggedMealplan.value.originalItem.mealPlan.id)
if (mealPlan != undefined) {
let fromToDiff = DateTime.fromJSDate(mealPlan.toDate).diff(DateTime.fromJSDate(mealPlan.fromDate), 'days')
let fromToDiff = {days: 1}
if (mealPlan.toDate) {
fromToDiff = DateTime.fromJSDate(mealPlan.toDate).diff(DateTime.fromJSDate(mealPlan.fromDate), 'days')
}
// create copy of item if control is pressed
if (event.ctrlKey) {
let new_entry = Object.assign({}, mealPlan)
new_entry.fromDate = targetDate
new_entry.toDate = DateTime.fromJSDate(targetDate).plus(fromToDiff).toJSDate()
useMealPlanStore().createObject(new_entry)
} else {
mealPlan.fromDate = targetDate
mealPlan.toDate = DateTime.fromJSDate(targetDate).plus(fromToDiff).toJSDate()

View File

@@ -4,7 +4,6 @@
<v-input :hint="props.hint" persistent-hint :label="props.label" class="" >
<!-- TODO resolve-on-load false for now, race condition with model class, make prop once better solution is found -->
<Multiselect
:ref="`ref_${props.id}`"

View File

@@ -14,11 +14,15 @@
<v-icon icon="fas fa-sliders-h"></v-icon>
<v-menu activator="parent">
<v-list>
<v-list-item prepend-icon="fas fa-plus-circle" @click="showName = true" v-if="!showName && (step.name == null || step.name == '')">{{
$t('Name')
}}
</v-list-item>
<v-list-item prepend-icon="fas fa-plus-circle" @click="showTime = true" v-if="!showTime && step.time == 0">{{ $t('Time') }}</v-list-item>
<v-list-item prepend-icon="fas fa-plus-circle" @click="showFile = true" v-if="!showFile && step.file == null">{{ $t('File') }}</v-list-item>
<v-list-item prepend-icon="fas fa-plus-circle" @click="showRecipe = true" v-if="!showRecipe && step.stepRecipe == null">{{ $t('Recipe') }}</v-list-item>
<v-list-item prepend-icon="$delete">{{ $t('Delete') }}</v-list-item>
<v-list-item prepend-icon="$delete" @click="emit('delete')">{{ $t('Delete') }}</v-list-item>
</v-list>
</v-menu>
</v-btn>
@@ -27,7 +31,8 @@
<v-card-text>
<v-text-field
v-model="step.name"
label="Step Name"
:label="$t('Name')"
v-if="showName || (step.name != null && step.name != '')"
></v-text-field>
<v-row>
@@ -46,20 +51,21 @@
<v-col cols="12">
<v-label>{{ $t('Ingredients') }}</v-label>
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients">
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients" v-if="!mobile">
<v-row v-for="(ingredient, index) in step.ingredients" dense>
<v-col cols="2">
<v-number-input :id="`id_input_amount_${step.id}_${index}`" :label="$t('Amount')" v-model="ingredient.amount" inset control-variant="stacked"
hide-details
:min="0"></v-number-input>
</v-col>
<v-col cols="3">
<model-select model="Unit" v-model="ingredient.unit"></model-select>
<model-select model="Unit" v-model="ingredient.unit" hide-details></model-select>
</v-col>
<v-col cols="3">
<model-select model="Food" v-model="ingredient.food"></model-select>
<model-select model="Food" v-model="ingredient.food" hide-details></model-select>
</v-col>
<v-col cols="3" @keydown.tab="event => handleIngredientNoteTab(event, index)">
<v-text-field :label="$t('Note')" v-model="ingredient.note"></v-text-field>
<v-text-field :label="$t('Note')" v-model="ingredient.note" hide-details></v-text-field>
</v-col>
<v-col cols="1">
<v-btn variant="plain" icon>
@@ -74,15 +80,36 @@
</v-col>
</v-row>
</vue-draggable>
<v-btn-group density="compact">
<v-list v-if="mobile">
<vue-draggable v-model="step.ingredients" handle=".drag-handle" :on-sort="sortIngredients">
<v-list-item v-for="(ingredient, index) in step.ingredients" border @click="editingIngredientIndex = index; dialogIngredientEditor = true">
<ingredient-string :ingredient="ingredient"></ingredient-string>
<template #append>
<v-icon icon="$dragHandle" class="drag-handle"></v-icon>
</template>
</v-list-item>
</vue-draggable>
</v-list>
<v-btn-group density="compact" class="mt-1">
<v-btn color="success" @click="insertAndFocusIngredient()" prepend-icon="$add">{{ $t('Add') }}</v-btn>
<v-btn color="warning" @click="dialogIngredientParser = true"><v-icon icon="$add"></v-icon> <v-icon icon="$add"></v-icon></v-btn>
<v-btn color="warning" @click="dialogIngredientParser = true">
<v-icon icon="$add"></v-icon>
<v-icon icon="$add"></v-icon>
</v-btn>
</v-btn-group>
</v-col>
<v-col cols="12">
<v-label>{{ $t('Instructions') }}</v-label>
<v-alert @click="dialogMarkdownEdit = true" class="mt-2 cursor-text" min-height="52px">
{{ step.instruction }}
<v-alert @click="dialogMarkdownEditor = true" class="mt-2 cursor-pointer" min-height="52px">
<template v-if="step.instruction != '' && step.instruction != null">
{{ step.instruction }}
</template>
<template v-else>
<i> {{ $t('InstructionsEditHelp') }} </i>
</template>
</v-alert>
</v-col>
</v-row>
@@ -92,12 +119,15 @@
</v-card>
<v-dialog
v-model="dialogMarkdownEdit"
v-model="dialogMarkdownEditor"
:max-width="(mobile) ? '100vw': '75vw'"
:fullscreen="mobile">
<v-card>
<v-closable-card-title :title="$t('Instructions')" v-model="dialogMarkdownEdit"></v-closable-card-title>
<v-closable-card-title :title="$t('Instructions')" v-model="dialogMarkdownEditor"></v-closable-card-title>
<step-markdown-editor class="h-100" v-model="step"></step-markdown-editor>
<v-card-actions v-if="!mobile">
<v-btn @click="dialogMarkdownEditor = false">{{ $t('Close') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -115,34 +145,59 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog
v-model="dialogIngredientEditor"
:max-width="(mobile) ? '100vw': '75vw'"
:fullscreen="mobile">
<v-card>
<v-closable-card-title :title="$t('Ingredient Editor')" v-model="dialogIngredientEditor"></v-closable-card-title>
<v-card-text>
<v-form>
<v-number-input v-model="step.ingredients[editingIngredientIndex].amount" inset control-variant="stacked" :label="$t('Amount')"
:min="0"></v-number-input>
<model-select model="Unit" v-model="step.ingredients[editingIngredientIndex].unit" :label="$t('Unit')"></model-select>
<model-select model="Food" v-model="step.ingredients[editingIngredientIndex].food" :label="$t('Food')"></model-select>
<v-text-field :label="$t('Note')" v-model="step.ingredients[editingIngredientIndex].note" ></v-text-field>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn @click="dialogIngredientEditor = false">{{ $t('Close') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import {nextTick, ref} from 'vue'
import {ApiApi, Food, Ingredient, ParsedIngredient, Step} from "@/openapi";
import {ApiApi, Ingredient, ParsedIngredient, Step} from "@/openapi";
import StepMarkdownEditor from "@/components/inputs/StepMarkdownEditor.vue";
import {VNumberInput} from 'vuetify/labs/VNumberInput' //TODO remove once component is out of labs
import IngredientsTableRow from "@/components/display/IngredientsTableRow.vue";
import {VNumberInput} from 'vuetify/labs/VNumberInput'
import ModelSelect from "@/components/inputs/ModelSelect.vue";
import {useDisplay} from "vuetify";
import {VueDraggable} from "vue-draggable-plus";
import VClosableCardTitle from "@/components/dialogs/VClosableCardTitle.vue";
import IngredientString from "@/components/display/IngredientString.vue";
const emit = defineEmits(['delete'])
const step = defineModel<Step>({required: true})
const props = defineProps({
stepIndex: {type: Number, required: true},
})
const {mobile} = useDisplay()
const showName = ref(false)
const showTime = ref(false)
const showRecipe = ref(false)
const showFile = ref(false)
const dialogMarkdownEdit = ref(false)
const dialogMarkdownEditor = ref(false)
const dialogIngredientEditor = ref(false)
const dialogIngredientParser = ref(false)
const editingIngredientIndex = ref({} as Ingredient)
const ingredientTextInput = ref("")
/**

View File

@@ -57,6 +57,7 @@
</v-col>
</v-row>
<closable-help-alert :text="$t('RecipeStepsHelp')" :action-text="$t('Steps')" @click="tab='steps'"></closable-help-alert>
</v-form>
</v-tabs-window-item>
@@ -64,13 +65,13 @@
<v-form :disabled="loading || fileApiLoading">
<v-row v-for="(s,i ) in editingObj.steps" :key="s.id">
<v-col>
<step-editor v-model="editingObj.steps[i]" :step-index="i"></step-editor>
<step-editor v-model="editingObj.steps[i]" :step-index="i" @delete="deleteStepAtIndex(i)"></step-editor>
</v-col>
</v-row>
<v-row>
<v-col class="text-center">
<v-btn-group density="compact">
<v-btn color="success" prepend-icon="fa-solid fa-plus">{{ $t('Add_Step') }}</v-btn>
<v-btn color="success" prepend-icon="fa-solid fa-plus" @click="addStep()">{{ $t('Add_Step') }}</v-btn>
<v-btn color="warning" @click="dialogStepManager = true">
<v-icon icon="fa-solid fa-arrow-down-1-9"></v-icon>
</v-btn>
@@ -124,7 +125,7 @@
<script setup lang="ts">
import {onMounted, PropType, ref, shallowRef} from "vue";
import {Recipe, Step} from "@/openapi";
import {Ingredient, Recipe, Step} from "@/openapi";
import ModelEditorBase from "@/components/model_editors/ModelEditorBase.vue";
import {useModelEditorFunctions} from "@/composables/useModelEditorFunctions";
import {useI18n} from "vue-i18n";
@@ -134,6 +135,7 @@ import {VueDraggable} from "vue-draggable-plus";
import PropertiesEditor from "@/components/inputs/PropertiesEditor.vue";
import {useFileApi} from "@/composables/useFileApi";
import {VFileUpload} from 'vuetify/labs/VFileUpload'
import ClosableHelpAlert from "@/components/display/ClosableHelpAlert.vue";
const props = defineProps({
@@ -183,6 +185,16 @@ function deleteImage() {
})
}
/**
* add a new step to the recipe
*/
function addStep(){
editingObj.value.steps.push({
ingredients: [] as Ingredient[],
time: 0,
} as Step)
}
/**
* called by draggable in step manager dialog when steps are sorted
*/
@@ -192,6 +204,14 @@ function sortSteps() {
})
}
/**
* delete a step at the given index of the steps array of the editingObject
* @param index index to delete at
*/
function deleteStepAtIndex(index: number){
editingObj.value.steps.splice(index, 1)
}
</script>
<style scoped>

View File

@@ -21,9 +21,10 @@
<br/>
<p class="text-h6 mt-3">{{ $t('DeviceSettings') }}</p>
<p class="text-disabled">{{$t('DeviceSettingsHelp')}}</p>
<p class="text-disabled">{{ $t('DeviceSettingsHelp') }}</p>
<v-btn @click="useUserPreferenceStore().resetDeviceSettings()">{{$t('Reset')}}</v-btn>
<v-btn @click="useUserPreferenceStore().resetDeviceSettings()" color="warning">{{ $t('Reset') }}</v-btn> <br/>
<v-btn @click="useUserPreferenceStore().deviceSettings.general_closedHelpAlerts = []" color="warning" class="mt-1">{{ $t('ResetHelp') }}</v-btn>
</v-form>
</template>
@@ -51,9 +52,9 @@ onMounted(() => {
})
})
function save(){
function save() {
let api = new ApiApi()
api.apiUserPartialUpdate({id: user.value.id!, patchedUser: user.value}).then(r => {
api.apiUserPartialUpdate({id: user.value.id!, patchedUser: user.value}).then(r => {
user.value = r
useMessageStore().addPreparedMessage(PreparedMessage.UPDATE_SUCCESS)
}).catch(err => {