Merge branch 'develop' into add-human-friendly-css-classes

This commit is contained in:
vabene1111
2024-02-18 08:04:27 +01:00
committed by GitHub
71 changed files with 3549 additions and 2931 deletions

View File

@@ -11,50 +11,90 @@
<b-button variant="primary" v-b-tooltip.hover :title="$t('Create')" @click="createNew">
<i class="fas fa-plus"></i>
</b-button>
<b-dropdown variant="primary" id="sortDropDown" text="Order By" class="border-left">
<b-dropdown-item @click = "orderBy('id','asc')" :disabled= "isActiveSort('id','asc')">oldest to newest</b-dropdown-item>
<b-dropdown-item @click = "orderBy('id','desc')" :disabled= "isActiveSort('id','desc')">newest to oldest</b-dropdown-item>
<b-dropdown-item @click = "orderBy('name','asc')" :disabled= "isActiveSort('name','asc')">alphabetical order</b-dropdown-item>
<b-dropdown-item @click = "orderBy('order','asc')" :disabled= "isActiveSort('order','asc')" >manually</b-dropdown-item>
</b-dropdown>
</b-input-group-append>
<b-button class= "ml-2" variant="primary" v-if="isActiveSort('order','asc')" @click="handleEditButton" >
{{submitText}}
</b-button>
</b-input-group>
</div>
</div>
</div>
</div>
</div>
<div style="padding-bottom: 55px">
<div class="mb-3" v-for="book in filteredBooks" :key="book.id">
<div class="row">
<div class="col-md-12">
<b-card class="d-flex flex-column" v-hover v-on:click="openBook(book.id)">
<b-row no-gutters style="height: inherit">
<b-col no-gutters md="10" style="height: inherit">
<b-card-body class="m-0 py-0" style="height: inherit">
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
<h5 class="m-0 mt-1 text-truncate">
{{ book.name }} <span class="float-right"><i class="fa fa-book"></i></span>
</h5>
<div class="m-0 text-truncate">{{ book.description }}</div>
<div class="mt-auto mb-1 d-flex flex-row justify-content-end"></div>
</b-card-text>
</b-card-body>
</b-col>
</b-row>
</b-card>
<div v-if="!isActiveSort('order','asc') || !manSubmitted">
<div style="padding-bottom: 55px">
<div class="mb-3" v-for="(book) in filteredBooks" :key="book.id">
<div class="row">
<div class="col-md-12">
<b-card class="d-flex flex-column" v-hover >
<b-row no-gutters style="height: inherit" class="d-flex align-items-center">
<b-col no-gutters style="height: inherit">
<b-card-body class="m-0 py-0" style="height: inherit">
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
<b-button v-on:click="openBook(book.id)" style="color: #000; background-color: white" variant="primary">
<h5 class="m-0 mt-1 text-truncate" >
{{ book.name }} <span class="float-right"><i class="fa fa-book"></i></span>
</h5></b-button>
<div class="m-0 text-truncate">{{ book.description }}</div>
<div class="mt-auto mb-1 d-flex flex-row justify-content-end"></div>
</b-card-text>
</b-card-body>
</b-col>
</b-row>
</b-card>
</div>
</div>
<loading-spinner v-if="current_book === book.id && loading"></loading-spinner>
<transition name="slide-fade">
<cookbook-slider
:recipes="recipes"
:book="book"
:key="`slider_${book.id}`"
v-if="current_book === book.id && !loading"
v-on:refresh="refreshData"
@reload="openBook(current_book, true)"
></cookbook-slider>
</transition>
</div>
</div>
<loading-spinner v-if="current_book === book.id && loading"></loading-spinner>
<transition name="slide-fade">
<cookbook-slider
:recipes="recipes"
:book="book"
:key="`slider_${book.id}`"
v-if="current_book === book.id && !loading"
v-on:refresh="refreshData"
@reload="openBook(current_book, true)"
></cookbook-slider>
</transition>
</div>
<div v-else>
<draggable
@change="updateManualSorting"
:list="cookbooks" ghost-class="ghost">
<b-card no-body class="mt-1 list-group-item p-2"
style="cursor: move"
v-for=" (book,index) in filteredBooks"
v-hover
:key="book.id">
<b-card-header class="p-2 border-0">
<div class="row">
<div class="col-2">
<button type="button"
class="btn btn-lg shadow-none"><i
class="fas fa-arrows-alt-v"></i></button>
</div>
<div class="col-10">
<h5 class="mt-1 mb-1">
<b-badge class="float-left text-white mr-2">
#{{ index + 1 }}
</b-badge>
{{ book.name }}
</h5>
</div>
</div>
</b-card-header>
</b-card>
</draggable>
</div>
</div>
<bottom-navigation-bar active-view="view_books">
<template #custom_create_functions>
@@ -72,7 +112,7 @@
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import draggable from "vuedraggable"
import "bootstrap-vue/dist/bootstrap-vue.css"
import { ApiApiFactory } from "@/utils/openapi/api"
import CookbookSlider from "@/components/CookbookSlider"
@@ -85,7 +125,7 @@ Vue.use(BootstrapVue)
export default {
name: "CookbookView",
mixins: [ApiMixin],
components: { LoadingSpinner, CookbookSlider, BottomNavigationBar },
components: { LoadingSpinner, CookbookSlider, BottomNavigationBar, draggable },
data() {
return {
cookbooks: [],
@@ -94,6 +134,11 @@ export default {
current_book: undefined,
loading: false,
search: "",
activeSortField : 'id',
activeSortDirection: 'desc',
inputValue: "",
manSubmitted : false,
submitText: "Edit"
}
},
computed: {
@@ -110,7 +155,7 @@ export default {
methods: {
refreshData: function () {
let apiClient = new ApiApiFactory()
apiClient.listRecipeBooks().then((result) => {
this.cookbooks = result.data
})
@@ -168,7 +213,45 @@ export default {
}
})
},
},
orderBy: function(order_field,order_direction){
let apiClient = new ApiApiFactory()
const options = {
order_field: order_field,
order_direction: order_direction
}
this.activeSortField = order_field
this.activeSortDirection = order_direction
apiClient.listRecipeBooks(options).then((result) => {
this.cookbooks = result.data
})
},
isActiveSort: function(field, direction) {
// Check if the current item is the active sorting option
return this.activeSortField === field && this.activeSortDirection === direction;
},
handleEditButton: function(){
if (!this.manSubmitted){
this.submitText = "Back"
this.manSubmitted = true
} else {
this.submitText = "Edit"
this.manSubmitted = false
}
},
updateManualSorting: function(){
let old_order = Object.assign({}, this.cookbooks);
let promises = []
this.cookbooks.forEach((element, index) => {
let apiClient = new ApiApiFactory()
promises.push(apiClient.partialUpdateManualOrderBooks(element.id, {order: index}))
})
return Promise.all(promises).then(() => {
}).catch((err) => {
this.cookbooks = old_order
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
}
},
directives: {
hover: {
inserted: function (el) {

View File

@@ -126,7 +126,7 @@
<div class="flex-grow-1 ml-2"
style="text-overflow: ellipsis; overflow-wrap: anywhere;">
<span class="two-row-text">
<a :href="resolveDjangoUrl('view_recipe', plan.entry.recipe.id)" v-if="plan.entry.recipe">{{ plan.entry.recipe.name }}</a>
<a :href="getRecipeURL(plan.entry.recipe, plan.entry.servings)" v-if="plan.entry.recipe">{{ plan.entry.recipe.name }}</a>
<span v-else>{{ plan.entry.title }}</span> <br/>
</span>
<span v-if="plan.entry.note" class="two-row-text">
@@ -169,7 +169,7 @@
v-if="contextData && contextData.originalItem && contextData.originalItem.entry.recipe != null"
@click="
$refs.menu.close()
openRecipe(contextData.originalItem.entry.recipe)
openRecipe(contextData.originalItem.entry.recipe, contextData.originalItem.entry.servings)
"
>
<a class="dropdown-item p-2" href="javascript:void(0)"><i class="fas fa-pizza-slice"></i>
@@ -392,8 +392,13 @@ export default {
},
},
methods: {
openRecipe: function (recipe) {
window.open(this.resolveDjangoUrl("view_recipe", recipe.id))
getRecipeURL: function (recipe, servings) {
return this.resolveDjangoUrl("view_recipe",`${recipe.id}?servings=${servings}`)
},
openRecipe: function (recipe, servings) {
window.open(this.getRecipeURL(recipe, servings))
},
setStartingDay(days) {
if (this.settings.startingDayOfWeek + days < 0) {

View File

@@ -38,11 +38,11 @@
@change="uploadImage($event.target.files[0])"/>
<div
class="h-100 w-100 border border-primary rounded"
style="border-width: 2px !important; border-style: dashed !important"
@drop.prevent="uploadImage($event.dataTransfer.files[0])"
@dragover.prevent
@click="$refs.file_upload.click()"
class="h-100 w-100 border border-primary rounded"
style="border-width: 2px !important; border-style: dashed !important"
@drop.prevent="uploadImage($event.dataTransfer.files[0])"
@dragover.prevent
@click="$refs.file_upload.click()"
>
<i class="far fa-image fa-10x text-primary"
style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%)"
@@ -71,27 +71,27 @@
<br/>
<label for="id_name"> {{ $t("Keywords") }}</label>
<multiselect
v-model="recipe.keywords"
:options="keywords"
:close-on-select="false"
:clear-on-select="true"
:hide-selected="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_keyword')"
:tag-placeholder="$t('add_keyword')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:taggable="true"
@tag="addKeyword"
label="label"
track-by="id"
id="id_keywords"
:multiple="true"
:loading="keywords_loading"
@search-change="searchKeywords"
v-model="recipe.keywords"
:options="keywords"
:close-on-select="false"
:clear-on-select="true"
:hide-selected="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_keyword')"
:tag-placeholder="$t('add_keyword')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:taggable="true"
@tag="addKeyword"
label="label"
track-by="id"
id="id_keywords"
:multiple="true"
:loading="keywords_loading"
@search-change="searchKeywords"
>
<template v-slot:noOptions>{{ $t("empty_list") }}</template>
</multiselect>
@@ -103,21 +103,21 @@
<div class="col-md-12">
<div class="card mt-2 mb-2">
<div class="card-body pr-2 pl-2 pr-md-5 pl-md-5 pt-3 pb-3">
<h6>{{ $t('Properties') }} <small class="text-muted"> {{$t('per_serving')}}</small></h6>
<h6>{{ $t('Properties') }} <small class="text-muted"> {{ $t('per_serving') }}</small></h6>
<div class="alert alert-info" role="alert">
{{ $t('recipe_property_info')}}
{{ $t('recipe_property_info') }}
</div>
<div class="d-flex mt-2" v-for="p in recipe.properties" v-bind:key="p.id">
<div class="flex-fill w-50">
<generic-multiselect
@change="p.property_type = $event.val"
:initial_single_selection="p.property_type"
:label="'name'"
:model="Models.PROPERTY_TYPE"
:limit="25"
:multiple="false"
@change="p.property_type = $event.val"
:initial_single_selection="p.property_type"
:label="'name'"
:model="Models.PROPERTY_TYPE"
:limit="25"
:multiple="false"
></generic-multiselect>
</div>
<div class="flex-fill w-50">
@@ -190,14 +190,14 @@
<br/>
<label> {{ $t("Share") }}</label>
<generic-multiselect
@change="recipe.shared = $event.val"
parent_variable="recipe.shared"
:initial_selection="recipe.shared"
:label="'display_name'"
:model="Models.USER_NAME"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Share')"
:limit="25"
@change="recipe.shared = $event.val"
parent_variable="recipe.shared"
:initial_selection="recipe.shared"
:label="'display_name'"
:model="Models.USER_NAME"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Share')"
:limit="25"
></generic-multiselect>
@@ -228,7 +228,7 @@
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
<button class="dropdown-item" @click="removeStep(step)"><i
class="fa fa-trash fa-fw"></i> {{ $t("Delete") }}
class="fa fa-trash fa-fw"></i> {{ $t("Delete") }}
</button>
<button class="dropdown-item" @click="moveStep(step, step_index - 1)"
@@ -249,7 +249,7 @@
<button class="dropdown-item" @click="setStepShowIngredientsTable(step, true)"
v-if="! step.show_ingredients_table">
<i class="op-icon fa fa-mavon-eye"></i> {{ $t("show_step_ingredients") }}
</button>
</button>
</div>
</div>
</div>
@@ -300,11 +300,11 @@
<i class="fas fa-plus-circle"></i> {{ $t("File") }}
</b-button>
<b-button
pill
variant="primary"
size="sm"
class="ml-1 mb-1 mb-md-0"
@click="
pill
variant="primary"
size="sm"
class="ml-1 mb-1 mb-md-0"
@click="
paste_step = step
$bvModal.show('id_modal_paste_ingredients')
"
@@ -327,31 +327,31 @@
<label :for="'id_step_' + step.id + '_file'">{{ $t("File") }}</label>
<b-input-group>
<multiselect
ref="file"
v-model="step.file"
:options="files"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:placeholder="$t('select_file')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:id="'id_step_' + step.id + '_file'"
label="name"
track-by="name"
:multiple="false"
:loading="files_loading"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
@search-change="searchFiles"
ref="file"
v-model="step.file"
:options="files"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:placeholder="$t('select_file')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:id="'id_step_' + step.id + '_file'"
label="name"
track-by="name"
:multiple="false"
:loading="files_loading"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
@search-change="searchFiles"
>
<template v-slot:noOptions>{{ $t("empty_list") }}</template>
</multiselect>
<b-input-group-append>
<b-button
variant="primary"
@click="
variant="primary"
@click="
step_for_file_create = step
show_file_create = true
"
@@ -367,24 +367,24 @@
<div class="col-md-12">
<label :for="'id_step_' + step.id + '_recipe'">{{ $t("Recipe") }}</label>
<multiselect
ref="step_recipe"
v-model="step.step_recipe"
:options="recipes.map((recipe) => recipe.id)"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_recipe')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:id="'id_step_' + step.id + '_recipe'"
:custom-label="(opt) => recipes.find((x) => x.id === opt).name"
:multiple="false"
:loading="recipes_loading"
@search-change="searchRecipes"
ref="step_recipe"
v-model="step.step_recipe"
:options="recipes.map((recipe) => recipe.id)"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_recipe')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:id="'id_step_' + step.id + '_recipe'"
:custom-label="(opt) => recipes.find((x) => x.id === opt).name"
:multiple="false"
:loading="recipes_loading"
@search-change="searchRecipes"
>
<template v-slot:noOptions>{{ $t("empty_list") }}</template>
</multiselect>
@@ -424,12 +424,12 @@
<div class="col-lg-2 col-md-6 small-padding"
v-if="!ingredient.is_header">
<input
class="form-control"
v-model="ingredient.amount"
type="number"
step="any"
v-if="!ingredient.no_amount"
:id="`amount_${step_index}_${index}`"
class="form-control"
v-model="ingredient.amount"
type="number"
step="any"
v-if="!ingredient.no_amount"
:id="`amount_${step_index}_${index}`"
/>
</div>
@@ -437,29 +437,29 @@
v-if="!ingredient.is_header">
<!-- search set to false to allow API to drive results & order -->
<multiselect
v-if="!ingredient.no_amount"
ref="unit"
v-model="ingredient.unit"
:options="units"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_unit')"
:tag-placeholder="$t('Create')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:taggable="true"
@tag="addUnitType"
:id="`unit_${step_index}_${index}`"
label="name"
track-by="name"
:multiple="false"
:loading="units_loading"
@search-change="searchUnits"
v-if="!ingredient.no_amount"
ref="unit"
v-model="ingredient.unit"
:options="units"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_unit')"
:tag-placeholder="$t('Create')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:taggable="true"
@tag="addUnitType"
:id="`unit_${step_index}_${index}`"
label="name"
track-by="name"
:multiple="false"
:loading="units_loading"
@search-change="searchUnits"
>
<template v-slot:noOptions>{{
$t("empty_list")
@@ -472,28 +472,28 @@
<!-- search set to false to allow API to drive results & order -->
<multiselect
ref="food"
v-model="ingredient.food"
:options="foods"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_food')"
:tag-placeholder="$t('Create')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:taggable="true"
@tag="addFoodType"
:id="`ingredient_${step_index}_${index}`"
label="name"
track-by="name"
:multiple="false"
:loading="foods_loading"
@search-change="searchFoods"
ref="food"
v-model="ingredient.food"
:options="foods"
:close-on-select="true"
:clear-on-select="true"
:allow-empty="true"
:preserve-search="true"
:internal-search="false"
:limit="options_limit"
:placeholder="$t('select_food')"
:tag-placeholder="$t('Create')"
:select-label="$t('Select')"
:selected-label="$t('Selected')"
:deselect-label="$t('remove_selection')"
:taggable="true"
@tag="addFoodType"
:id="`ingredient_${step_index}_${index}`"
label="name"
track-by="name"
:multiple="false"
:loading="foods_loading"
@search-change="searchFoods"
>
<template v-slot:noOptions>{{
$t("empty_list")
@@ -504,11 +504,11 @@
<div class="small-padding"
v-bind:class="{ 'col-lg-4 col-md-6': !ingredient.is_header, 'col-lg-12 col-md-12': ingredient.is_header }">
<input
class="form-control"
maxlength="256"
v-model="ingredient.note"
v-bind:placeholder="$t('Note')"
v-on:keydown.tab="
class="form-control"
maxlength="256"
v-model="ingredient.note"
v-bind:placeholder="$t('Note')"
v-on:keydown.tab="
(event) => {
if (step.ingredients.indexOf(ingredient) === step.ingredients.length - 1) {
event.preventDefault()
@@ -522,13 +522,13 @@
<div class="flex-grow-0 small-padding">
<a
class="btn shadow-none btn-lg pr-1 pl-0 pr-md-2 pl-md-2"
href="#"
role="button"
id="dropdownMenuLink2"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
class="btn shadow-none btn-lg pr-1 pl-0 pr-md-2 pl-md-2"
href="#"
role="button"
id="dropdownMenuLink2"
data-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false"
>
<i class="fas fa-ellipsis-v text-muted"></i>
</a>
@@ -645,7 +645,18 @@
<mavon-editor v-model="step.instruction" :autofocus="false"
style="z-index: auto" :id="'id_instruction_' + step.id"
:language="'en'"
:toolbars="md_editor_toolbars" :defaultOpen="'edit'"/>
:toolbars="md_editor_toolbars" :defaultOpen="'edit'">
<template #left-toolbar-after>
<span class="op-icon-divider"></span>
<button
type="button"
@click="step.instruction+= ' {{ scale(100) }}'"
class="op-icon fas fa-times"
aria-hidden="true"
title="Scalable Number"
></button>
</template>
</mavon-editor>
<!-- TODO markdown DOCS link and markdown editor -->
</div>
@@ -662,7 +673,7 @@
</button>
<button type="button" v-b-modal:id_modal_sort class="btn btn-warning shadow-none"><i
class="fas fa-sort-amount-down-alt fa-lg"></i></button>
class="fas fa-sort-amount-down-alt fa-lg"></i></button>
</b-button-group>
</div>
</div>
@@ -694,7 +705,7 @@
<div class="col-3 col-md-6 mb-1 mb-md-0 pr-2 pl-2">
<a :href="resolveDjangoUrl('delete_recipe', recipe.id)"
class="d-block d-md-none btn btn-block btn-danger shadow-none btn-sm"><i
class="fa fa-trash fa-lg"></i></a>
class="fa fa-trash fa-lg"></i></a>
<a :href="resolveDjangoUrl('delete_recipe', recipe.id)"
class="d-none d-md-block btn btn-block btn-danger shadow-none btn-sm">{{ $t("Delete") }}</a>
</div>
@@ -749,11 +760,11 @@
<!-- modal for pasting list of ingredients -->
<b-modal
id="id_modal_paste_ingredients"
v-bind:title="$t('ingredient_list')"
@ok="appendIngredients(paste_step)"
@cancel="paste_ingredients = paste_step = undefined"
@close="paste_ingredients = paste_step = undefined"
id="id_modal_paste_ingredients"
v-bind:title="$t('ingredient_list')"
@ok="appendIngredients(paste_step)"
@cancel="paste_ingredients = paste_step = undefined"
@close="paste_ingredients = paste_step = undefined"
>
<b-form-textarea id="paste_ingredients" v-model="paste_ingredients"
:placeholder="$t('paste_ingredients_placeholder')" rows="10"></b-form-textarea>
@@ -832,7 +843,7 @@ export default {
header: true,
underline: true,
strikethrough: true,
mark: true,
mark: false,
superscript: true,
subscript: true,
quote: true,
@@ -1293,7 +1304,7 @@ export default {
})
Promise.allSettled(promises).then(() => {
ing_list.forEach(ing => {
if(ing.trim() !== ""){
if (ing.trim() !== "") {
step.ingredients.push(parsed_ing_list.find(x => x.original_text === ing))
}
})

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,48 @@
<div id="app">
<div>
<markdown-editor-component></markdown-editor-component>
<div class="swipe-container">
<div class="swipe-action bg-success">
<i class="swipe-icon fa-fw fas fa-check"></i>
</div>
<b-button-group class="swipe-element">
<div class="card flex-grow-1 btn-block p-2">
<div class="d-flex">
<div class="d-flex flex-column pr-2">
<span>
<span><i class="fas fa-check"></i> <b>100 g </b></span>
<br/>
</span>
<span>
<span><i class="fas fa-check"></i> <b>200 kg </b></span>
<br/>
</span>
</div>
<div class="d-flex flex-column flex-grow-1 align-self-center">
Erdbeeren <br/>
<span><small class="text-muted">vabene111</small></span>
</div>
</div>
</div>
<b-button variant="success">
<i class="d-print-none fa-fw fas fa-check"></i>
</b-button>
</b-button-group>
<div class="swipe-action bg-primary justify-content-end">
<i class="fa-fw fas fa-hourglass-half swipe-icon"></i>
</div>
</div>
<markdown-editor-component></markdown-editor-component>
</div>
</div>
</template>
@@ -49,5 +89,46 @@ export default {
</script>
<style>
.swipe-container {
display: flex;
overflow: auto;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
/* scrollbar should be hidden */
.swipe-container::-webkit-scrollbar {
display: none;
}
.swipe-container {
scrollbar-width: none; /* For Firefox */
}
/* main element should always snap into view */
.swipe-element {
scroll-snap-align: start;
}
.swipe-icon {
color: white;
position: sticky;
left: 16px;
right: 16px;
}
/* swipe-actions and element should be 100% wide */
.swipe-action,
.swipe-element {
min-width: 100%;
}
.swipe-action {
display: flex;
align-items: center;
}
.right {
justify-content: flex-end;
}
</style>

View File

@@ -11,14 +11,14 @@
<div class="flex-column" v-if="show_button_1">
<slot name="button_1">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_search' }" v-bind:href="resolveDjangoUrl('view_search')">
<i class="fas fa-fw fa-book " style="font-size: 1.5em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
<i class="fas fa-fw fa-book " style="font-size: 1.4em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
</slot>
</div>
<div class="flex-column" v-if="show_button_2">
<slot name="button_2">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_plan' }" v-bind:href="resolveDjangoUrl('view_plan')">
<i class="fas fa-calendar-alt" style="font-size: 1.5em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
<i class="fas fa-calendar-alt" style="font-size: 1.4em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
</slot>
</div>
@@ -53,14 +53,14 @@
<div class="flex-column" v-if="show_button_3">
<slot name="button_3">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_shopping' }" v-bind:href="resolveDjangoUrl('view_shopping')">
<i class="fas fa-shopping-cart" style="font-size: 1.5em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
<i class="fas fa-shopping-cart" style="font-size: 1.4em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
</slot>
</div>
<div class="flex-column">
<slot name="button_4" v-if="show_button_4">
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_books' }" v-bind:href="resolveDjangoUrl('view_books')">
<i class="fas fa-book-open" style="font-size: 1.5em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
<i class="fas fa-book-open" style="font-size: 1.4em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
</slot>
</div>

View File

@@ -6,7 +6,6 @@
</template>
<script>
import html2pdf from "html2pdf.js"
export default {
name: "DownloadPDF",
@@ -20,12 +19,7 @@ export default {
},
methods: {
downloadFile() {
const doc = document.querySelector(this.dom)
var options = {
margin: 1,
filename: this.name,
}
html2pdf().from(doc).set(options).save()
window.print()
},
},
}

View File

@@ -1,44 +1,60 @@
<template>
<div>
<h1>EDITOR</h1>
<div id="editor" style="" class="bg-info">
<b-button @click="toolbarTest()">Heading</b-button>
<b-button @click="bold()">B</b-button>
<div id="editor" style="background-color: #fff; border: solid 1px">
</div>
</div>
</template>
<script>
import {EditorState} from "@codemirror/state"
import {keymap, EditorView, MatchDecorator, Decoration, WidgetType, ViewPlugin} from "@codemirror/view"
import {defaultKeymap} from "@codemirror/commands"
import {EditorSelection, EditorState} from "@codemirror/state"
import {keymap, EditorView, MatchDecorator, Decoration, WidgetType, ViewPlugin, DecorationSet} from "@codemirror/view"
import {defaultKeymap, history} from "@codemirror/commands"
import {markdown} from "@codemirror/lang-markdown"
import {markdown, markdownLanguage} from "@codemirror/lang-markdown"
import {autocompletion} from "@codemirror/autocomplete"
class PlaceholderWidget extends WidgetType { //TODO this is not working for some javascript magic reason
import {defaultHighlightStyle, syntaxHighlighting, syntaxTree} from "@codemirror/language";
class TemplatePreviewWidget extends WidgetType {
name = undefined
constructor(name) {
console.log(name)
ingredients = []
constructor(name, ingredients) {
super()
this.name = name
this.ingredients = ingredients
}
eq(other) {
return this.name == other.name
getIngredientLabel(ingredient) {
// TODO all possible null combinations
return `${ingredient.amount} ${ingredient.unit.name} ${ingredient.food.name}`
}
toDOM() {
let elt = document.createElement("span")
elt.style.cssText = `
border: 1px solid blue;
border-radius: 4px;
padding: 0 3px;
background: lightblue;`
let preview_span = document.createElement("span")
elt.textContent = "Food"
return elt
let display_text = 'ERROR'
if (this.name.includes('ingredients')) {
let ingredient_index = this.name.replace('{{ ingredients[', '').replace('] }}', '') // TODO support calculations ingredients[0]*0.5
display_text = this.getIngredientLabel(this.ingredients[ingredient_index])
}
if (this.name.includes('scale(')) {
display_text = this.name.replace('{{ scale(', '').replace(') }}', '') // TODO support calculations scale(100)*2
}
preview_span.innerHTML = display_text
preview_span.style.cssText = ` border: 1px solid blue; border-radius: 4px; padding: 0 3px; background: lightblue;`
return preview_span
}
ignoreEvent() {
@@ -49,39 +65,69 @@ class PlaceholderWidget extends WidgetType { //TODO this is not working for some
export default {
name: "MarkdownEditorComponent",
props: {},
computed: {},
computed: {
autocomplete_options() {
let autocomplete_options = []
let index = 0
for (let i of this.ingredients) {
autocomplete_options.push({label: i.food.name, type: "text", apply: `{{ ingredients[${index}] }}`, detail: `${i.amount} ${i.unit.name} ${i.food.name}`})
index++
}
autocomplete_options.push({label: "Scale", type: "text", apply: "{{ scale(100) }}", detail: "simple scalable number"})
return autocomplete_options
}
},
data() {
return {
editor_view: null,
ingredients: [
{amount: 20, food: {'name': 'raspberry'}, unit: {'name': 'pcs'}},
{amount: 100, food: {'name': 'sugar'}, unit: {'name': 'g'}},
{amount: 250, food: {'name': 'water'}, unit: {'name': 'ml'}},
{amount: 1, food: {'name': 'salt'}, unit: {'name': 'pinch'}},
]
}
},
mounted() {
const placeholderMatcher = new MatchDecorator({
regexp: /\{\{\singredients\[\d\]\s\}\}/g,
decoration: match => Decoration.replace({
widget: new PlaceholderWidget(match[0]),
})
const decoMatcher = new MatchDecorator({
regexp: /\{\{ (?:scale\(\d+\)|ingredients\[\d+\]) \}\}/g,
decorate: (add, from, to, match, view) => {
const templatePreview = new TemplatePreviewWidget(match[0], this.ingredients);
add(to, to, Decoration.widget({widget: templatePreview, side: 1}));
},
})
const placeholders = ViewPlugin.fromClass(class {
placeholders
decorations
constructor(view) {
this.placeholders = placeholderMatcher.createDeco(view)
this.decorations = decoMatcher.createDeco(view)
}
update(update) {
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders)
update(viewUpdate) {
this.decorations = decoMatcher.updateDeco(viewUpdate, this.decorations)
}
}, {
decorations: instance => instance.placeholders,
provide: plugin => EditorView.atomicRanges.of(view => {
return view.plugin(plugin)?.placeholders || Decoration.none
})
decorations: instance => instance.decorations,
})
let startState = EditorState.create({
doc: "Das ist eine Beschreibung \nPacke {{ ingredients[1] }} in das Fass mit {{ ingredients[3] }}\nTest Bla Bla",
extensions: [keymap.of(defaultKeymap), placeholders, markdown(), autocompletion({override: [this.foodTemplateAutoComplete]})]
doc: "## Header\n\nDas ist eine Beschreibung [test](https://google.com) \nPacke {{ ingredients[1] }} in das Fass mit {{ ingredients[3] }}\nTest Bla Bla {{ scale(100) }} \n\n- test\n- test 2\n- test3",
extensions: [
history(),
syntaxHighlighting(defaultHighlightStyle),
keymap.of(defaultKeymap),
placeholders,
markdown({base: markdownLanguage, highlightFormatting: true}),
autocompletion({override: [this.foodTemplateAutoComplete]})
]
})
let view = new EditorView({
this.editor_view = new EditorView({
state: startState,
extensions: [],
parent: document.getElementById("editor")
@@ -90,16 +136,74 @@ export default {
methods: {
foodTemplateAutoComplete: function (context) {
let word = context.matchBefore(/\w*/)
if (word.from == word.to && !context.explicit)
if (word.from === word.to && !context.explicit)
return null
return {
from: word.from,
options: [
{label: "Mehl", type: "text", apply: "{{ ingredients[1] }}", detail: "template"},
{label: "Butter", type: "text", apply: "{{ ingredients[2] }}", detail: "template"},
{label: "Salz", type: "text", apply: "{{ ingredients[3] }}", detail: "template"},
]
options: this.autocomplete_options
}
},
toolbarTest() {
const transaction = this.editor_view.state.changeByRange((range) => {
const prefix = '###'
const docText = this.editor_view.state.doc.toString()
let text = this.editor_view.state.sliceDoc(range.from, range.to)
let rangeFrom = range.from
while (rangeFrom > 0) {
if (docText[rangeFrom - 1] === "\n") {
break
}
rangeFrom -= 1
}
text = this.editor_view.state.sliceDoc(rangeFrom, range.to)
const textBefore = `\n${text}`
const newText = textBefore.replace(/\n/g, `\n${prefix}`)
const changes = {
from: rangeFrom,
to: range.to,
insert: newText,
anchor: rangeFrom + prefix.length + 1,
textBefore: textBefore
}
return {
changes,
range: EditorSelection.range(changes.anchor, changes.anchor)
}
})
this.editor_view.dispatch(transaction)
},
bold() {
const transaction = this.editor_view.state.changeByRange((range) => {
if (range.anchor === range.head) {
console.log('nothing selected --> nothing bold')
} else {
let selected_text = this.editor_view.state.sliceDoc(range.from, range.to)
let new_text = `**${selected_text}**`
const changes = {
from: range.from,
to: range.to,
insert: new_text,
}
return {changes, range: EditorSelection.range(range.anchor + 2, range.head + 2)}
}
})
this.editor_view.dispatch(transaction)
},
heading(editor, heading_size) {
}
},
}

View File

@@ -186,8 +186,5 @@ export default {
}
</script>
<style>
.b-form-spinbutton.form-control {
background-color: #e9ecef;
border: 1px solid #ced4da;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<div>
<b-button-group class="w-100 mt-1">
<b-button @click="updateNumber( 'half')" variant="outline-info"
:disabled="disable"><i class="fas fa-divide"></i> 2
</b-button>
<b-button variant="outline-info" @click="updateNumber( 'sub')"
:disabled="disable"><i class="fas fa-minus"></i>
</b-button>
<b-button variant="outline-info" @click="updateNumber('prompt')"
:disabled="disable">
{{ number }}
</b-button>
<b-button variant="outline-info" @click="updateNumber( 'add')"
:disabled="disable"><i class="fas fa-plus"></i>
</b-button>
<b-button @click="updateNumber('multiply')" variant="outline-info"
:disabled="disable"><i class="fas fa-times"></i> 2
</b-button>
</b-button-group>
</div>
</template>
<script>
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
Vue.use(BootstrapVue)
export default {
name: "NumberScalerComponent",
props: {
number: {type: Number, default:0},
disable: {type: Boolean, default: false}
},
data() {
return {}
},
methods: {
/**
* perform given operation on linked number
* @param operation update mode
*/
updateNumber: function(operation) {
if (operation === 'half') {
this.$emit('change', this.number / 2)
}
if (operation === 'multiply') {
this.$emit('change', this.number * 2)
}
if (operation === 'add') {
this.$emit('change', this.number + 1)
}
if (operation === 'sub') {
this.$emit('change', this.number - 1)
}
if (operation === 'prompt') {
let input_number = prompt(this.$t('Input'), this.number);
if (input_number !== null && input_number !== "" && !isNaN(input_number) && !isNaN(parseFloat(input_number))) {
this.$emit('change', parseFloat(input_number))
} else {
console.log('Invalid number input in prompt', input_number)
}
}
},
},
}
</script>
<style scoped>
</style>

View File

@@ -4,7 +4,7 @@
<div v-if="metadata !== undefined">
{{ $t('Data_Import_Info') }}
<a href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{$t('Learn_More')}}</a>
<a href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{ $t('Learn_More') }}</a>
<select class="form-control" v-model="selected_version">
@@ -18,13 +18,17 @@
<div v-if="selected_version !== undefined" class="mt-3">
<table class="table">
<tr>
<th>{{ $t('Import') }}</th>
<th>{{ $t('Datatype') }}</th>
<th>{{ $t('Number of Objects') }}</th>
<th>{{ $t('Imported') }}</th>
</tr>
<tr v-for="d in metadata.datatypes" v-bind:key="d">
<td>{{ $t(d.charAt(0).toUpperCase() + d.slice(1)) }}</td>
<td>{{ metadata[selected_version][d] }}</td>
<tr v-for="d in datatypes" v-bind:key="d.name">
<td>
<b-checkbox v-model="d.selected"></b-checkbox>
</td>
<td>{{ $t(d.name.charAt(0).toUpperCase() + d.name.slice(1)) }}</td>
<td>{{ metadata[selected_version][d.name] }}</td>
<td>
<template v-if="import_count !== undefined">{{ import_count[d] }}</template>
</td>
@@ -59,6 +63,7 @@ export default {
data() {
return {
metadata: undefined,
datatypes: {},
selected_version: undefined,
update_existing: true,
use_metric: true,
@@ -70,6 +75,12 @@ export default {
axios.get(resolveDjangoUrl('api_import_open_data')).then(r => {
this.metadata = r.data
for (let i in this.metadata.datatypes) {
this.datatypes[this.metadata.datatypes[i]] = {
name: this.metadata.datatypes[i],
selected: false,
}
}
}).catch(err => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
@@ -78,7 +89,7 @@ export default {
doImport: function () {
axios.post(resolveDjangoUrl('api_import_open_data'), {
'selected_version': this.selected_version,
'selected_datatypes': this.metadata.datatypes,
'selected_datatypes': this.datatypes,
'update_existing': this.update_existing,
'use_metric': this.use_metric,
}).then(r => {

View File

@@ -238,6 +238,7 @@ export default {
},
props: {
recipe_id: Number,
def_servings: Number,
recipe_obj: {type: Object, default: null},
show_context_menu: {type: Boolean, default: true},
enable_keyword_links: {type: Boolean, default: true},
@@ -321,8 +322,13 @@ export default {
if (this.recipe.image === null) this.printReady()
this.servings = this.servings_cache[this.rootrecipe.id] = this.recipe.servings
window.RECIPE_SERVINGS = Number(window.RECIPE_SERVINGS)
if (window.RECIPE_SERVINGS && ! isNaN(window.RECIPE_SERVINGS)) {
//I am not sure this is the best way. This overwrites our servings cache, which may not be intended?
this.servings = window.RECIPE_SERVINGS
} else {
this.servings = this.servings_cache[this.rootrecipe.id] = this.recipe.servings
}
this.loading = false
setTimeout(() => {

View File

@@ -1,10 +1,10 @@
<template>
<div v-if="user_preferences !== undefined">
<div v-if="useUserPreferenceStore().user_settings !== undefined">
<b-form-group :label="$t('shopping_share')" :description="$t('shopping_share_desc')">
<generic-multiselect
@change="user_preferences.shopping_share = $event.val; updateSettings(false)"
@change="useUserPreferenceStore().user_settings.shopping_share = $event.val; updateSettings(false)"
:model="Models.USER"
:initial_selection="user_preferences.shopping_share"
:initial_selection="useUserPreferenceStore().user_settings.shopping_share"
label="display_name"
:multiple="true"
:placeholder="$t('User')"
@@ -12,101 +12,98 @@
</b-form-group>
<b-form-group :label="$t('shopping_auto_sync')" :description="$t('shopping_auto_sync_desc')">
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="user_preferences.shopping_auto_sync"
@change="updateSettings(false)"></b-form-input>
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="useUserPreferenceStore().user_settings.shopping_auto_sync"
@change="updateSettings(false)" :disabled="useUserPreferenceStore().user_settings.shopping_auto_sync < 1"></b-form-input>
<div class="text-center">
<span v-if="user_preferences.shopping_auto_sync > 0">
{{ Math.round(user_preferences.shopping_auto_sync) }}
<span v-if="user_preferences.shopping_auto_sync === 1">{{ $t('Second') }}</span>
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">
{{ Math.round(useUserPreferenceStore().user_settings.shopping_auto_sync) }}
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync === 1">{{ $t('Second') }}</span>
<span v-else> {{ $t('Seconds') }}</span>
</span>
<span v-if="user_preferences.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
</div>
<br/>
<b-button class="btn btn-sm" @click="user_preferences.shopping_auto_sync = 0; updateSettings(false)">{{ $t('Disabled') }}</b-button>
<b-button class="btn btn-sm" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = 0; updateSettings(false)"
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">{{ $t('Disable') }}</b-button>
<b-button class="btn btn-sm btn-success" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = SHOPPING_MIN_AUTOSYNC_INTERVAL; updateSettings(false)"
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Enable') }}</b-button>
</b-form-group>
<b-form-group :description="$t('mealplan_autoadd_shopping_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoadd_shopping"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoadd_shopping"
@change="updateSettings(false)">{{ $t('mealplan_autoadd_shopping') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('mealplan_autoexclude_onhand_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoexclude_onhand"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoexclude_onhand"
@change="updateSettings(false)">{{ $t('mealplan_autoexclude_onhand') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('mealplan_autoinclude_related_desc')">
<b-form-checkbox v-model="user_preferences.mealplan_autoinclude_related"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoinclude_related"
@change="updateSettings(false)">{{ $t('mealplan_autoinclude_related') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :description="$t('shopping_add_onhand_desc')">
<b-form-checkbox v-model="user_preferences.shopping_add_onhand"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.shopping_add_onhand"
@change="updateSettings(false)">{{ $t('shopping_add_onhand') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :label="$t('default_delay')" :description="$t('default_delay_desc')">
<b-form-input type="range" min="1" max="72" step="1" v-model="user_preferences.default_delay"
<b-form-input type="range" min="1" max="72" step="1" v-model="useUserPreferenceStore().user_settings.default_delay"
@change="updateSettings(false)"></b-form-input>
<div class="text-center">
<span>{{ Math.round(user_preferences.default_delay) }}
<span v-if="user_preferences.default_delay === 1">{{ $t('Hour') }}</span>
<span>{{ Math.round(useUserPreferenceStore().user_settings.default_delay) }}
<span v-if="useUserPreferenceStore().user_settings.default_delay === 1">{{ $t('Hour') }}</span>
<span v-else> {{ $t('Hours') }}</span>
</span>
</div>
</b-form-group>
<b-form-group :description="$t('filter_to_supermarket_desc')">
<b-form-checkbox v-model="user_preferences.filter_to_supermarket"
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.filter_to_supermarket"
@change="updateSettings(false)">{{ $t('filter_to_supermarket') }}
</b-form-checkbox>
</b-form-group>
<b-form-group :label="$t('shopping_recent_days')" :description="$t('shopping_recent_days_desc')">
<b-form-input type="range" min="0" max="14" step="1" v-model="user_preferences.shopping_recent_days"
<b-form-input type="range" min="0" max="14" step="1" v-model="useUserPreferenceStore().user_settings.shopping_recent_days"
@change="updateSettings(false)"></b-form-input>
<div class="text-center">
<span>{{ Math.round(user_preferences.shopping_recent_days) }}
<span v-if="user_preferences.shopping_recent_days === 1">{{ $t('Day') }}</span>
<span>{{ Math.round(useUserPreferenceStore().user_settings.shopping_recent_days) }}
<span v-if="useUserPreferenceStore().user_settings.shopping_recent_days === 1">{{ $t('Day') }}</span>
<span v-else> {{ $t('Days') }}</span>
</span>
</div>
</b-form-group>
<b-form-group :label="$t('csv_delim_label')" :description="$t('csv_delim_help')">
<b-form-input v-model="user_preferences.csv_delim" @change="updateSettings(false)"></b-form-input>
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_delim" @change="updateSettings(false)"></b-form-input>
</b-form-group>
<b-form-group :label="$t('csv_prefix_label')" :description="$t('csv_prefix_help')">
<b-form-input v-model="user_preferences.csv_prefix" @change="updateSettings(false)"></b-form-input>
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_prefix" @change="updateSettings(false)"></b-form-input>
</b-form-group>
</div>
</template>
<script>
import {ApiApiFactory} from "@/utils/openapi/api";
import {ApiMixin, StandardToasts} from "@/utils/utils";
import axios from "axios";
import GenericMultiselect from "@/components/GenericMultiselect";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
export default {
name: "ShoppingSettingsComponent",
mixins: [ApiMixin],
components: {GenericMultiselect},
props: {
user_id: Number,
},
props: { },
data() {
return {
user_preferences: undefined,
@@ -115,30 +112,15 @@ export default {
}
},
mounted() {
this.user_preferences = this.preferences
this.languages = window.AVAILABLE_LANGUAGES
this.loadSettings()
useUserPreferenceStore().loadUserSettings(false)
},
methods: {
loadSettings: function () {
let apiFactory = new ApiApiFactory()
apiFactory.retrieveUserPreference(this.user_id.toString()).then(result => {
this.user_preferences = result.data
}).catch(err => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
},
useUserPreferenceStore,
updateSettings: function (reload) {
let apiFactory = new ApiApiFactory()
this.$emit('updated', this.user_preferences)
apiFactory.partialUpdateUserPreference(this.user_id.toString(), this.user_preferences).then(result => {
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
if (reload) {
location.reload()
}
}).catch(err => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
useUserPreferenceStore().updateUserSettings()
},
}
}

View File

@@ -1,176 +1,134 @@
<template>
<div id="shopping_line_item" class="pt-1">
<b-row align-h="start"
ref="shopping_line_item" class="invis-border">
<b-col cols="2" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0"
variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i></div>
</b-button>
</b-col>
<b-col cols="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, entries)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-0 pl-1 pr-md-3 pl-md-3 dropdown-toggle-no-caret">
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="swipe-container" :id="item_container_id" @touchend="handleSwipe()"
v-if="(useUserPreferenceStore().device_settings.shopping_show_checked_entries || !is_checked) && (useUserPreferenceStore().device_settings.shopping_show_delayed_entries || !is_delayed)"
>
<div class="swipe-action" :class="{'bg-success': !is_checked , 'bg-warning': is_checked }">
<i class="swipe-icon fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
</div>
<b-button-group class="swipe-element">
<b-button variant="primary" v-if="is_delayed">
<i class="fa-fw fas fa-hourglass-half"></i>
</b-button>
<div class="card flex-grow-1 btn-block p-2" @click="detail_modal_visible = true">
<div class="d-flex">
<div class="d-flex flex-column pr-2" v-if="Object.keys(amounts).length> 0">
<span v-for="a in amounts" v-bind:key="a.id">
<span><i class="fas fa-check" v-if="a.checked && !is_checked"></i><i class="fas fa-hourglass-half" v-if="a.delayed && !a.checked"></i> <b>{{ a.amount }} {{
a.unit
}} </b></span>
<br/></span>
</div>
<div class="d-flex flex-column flex-grow-1 align-self-center">
{{ food.name }} <br/>
<span v-if="info_row"><small class="text-muted">{{ info_row }}</small></span>
</div>
</div>
</b-col>
<b-col cols="1" class="px-1 justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
<b-col cols="8">
<b-row class="d-flex h-100">
<b-col cols="6" md="3" class="d-flex align-items-center" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
v-if="Object.entries(formatAmount).length == 1">
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong>
{{ Object.entries(formatAmount)[0][0] }}
</b-col>
<b-col cols="6" md="3" class="d-flex flex-column" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
v-if="Object.entries(formatAmount).length != 1">
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">
{{ x[1] }} &ensp;
{{ x[0] }}
</div>
</div>
<b-button variant="success" @click="useShoppingListStore().setEntriesCheckedState(entries, !is_checked, true)"
:class="{'btn-success': !is_checked, 'btn-warning': is_checked}">
<i class="d-print-none fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
</b-button>
</b-button-group>
<div class="swipe-action bg-primary justify-content-end">
<i class="fa-fw fas fa-hourglass-half swipe-icon"></i>
</div>
<b-modal v-model="detail_modal_visible" @hidden="detail_modal_visible = false" body-class="pr-4 pl-4 pt-0">
<template #modal-title>
<h5> {{ food_row }}</h5>
<small class="text-muted">{{ food.description }}</small>
</template>
<template #default>
<h5 class="mt-2">{{ $t('Quick actions') }}</h5>
{{ $t('Category') }}
<b-form-select
class="form-control mb-2"
:options="useShoppingListStore().supermarket_categories"
text-field="name"
value-field="id"
v-model="food.supermarket_category"
@change="detail_modal_visible = false; updateFoodCategory(food)"
></b-form-select>
<b-button variant="info" block
@click="detail_modal_visible = false;useShoppingListStore().delayEntries(entries,!is_delayed, true)">
{{ $t('Postpone') }}
</b-button>
<h6 class="mt-2">{{ $t('Entries') }}</h6>
<b-row v-for="e in entries" v-bind:key="e.id">
<b-col cold="12">
<b-button-group class="w-100">
<div class="card flex-grow-1 btn-block p-2">
<span><i class="fas fa-check" v-if="e.checked"></i><i class="fas fa-hourglass-half" v-if="e.delay_until !== null && !e.checked"></i>
<b><span v-if="e.amount > 0">{{ e.amount }}</span> {{ e.unit?.name }}</b> {{ food.name }}</span>
<span><small class="text-muted">
<span v-if="e.recipe_mealplan && e.recipe_mealplan.recipe_name !== ''">
<a :href="resolveDjangoUrl('view_recipe', e.recipe_mealplan.recipe)"> <b> {{
e.recipe_mealplan.recipe_name
}} </b></a>({{
e.recipe_mealplan.servings
}} {{ $t('Servings') }})<br/>
</span>
<span
v-if="e.recipe_mealplan && e.recipe_mealplan.mealplan_type !== undefined"> {{
e.recipe_mealplan.mealplan_type
}} {{ formatDate(e.recipe_mealplan.mealplan_from_date) }} <br/></span>
{{ e.created_by.display_name }} {{ formatDate(e.created_at) }}<br/>
</small></span>
</div>
<b-button variant="outline-danger"
@click="useShoppingListStore().deleteObject(e)"><i
class="fas fa-trash"></i></b-button>
</b-button-group>
<generic-multiselect
class="mt-1"
v-if="e.recipe_mealplan === null"
:initial_single_selection="e.unit"
:model="Models.UNIT"
:multiple="false"
@change="e.unit = $event.val; useShoppingListStore().updateObject(e)"
>
</generic-multiselect>
<number-scaler-component :number="e.amount"
@change="e.amount = $event; useShoppingListStore().updateObject(e)"
v-if="e.recipe_mealplan === null"></number-scaler-component>
<hr class="m-2"/>
</b-col>
<b-col cols="6" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatFood }}
</b-col>
<b-col cols="3" data-html2canvas-ignore="true" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
class="align-items-center d-none d-md-flex justify-content-end">
<b-button size="sm" @click="showDetails = !showDetails"
class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none" variant="link">
<div class="text-nowrap">
<i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
<span class="d-none d-md-inline-block"><span class="ml-2">{{
$t("Details")
}}</span></span>
</div>
</b-button>
</b-col>
</b-row>
</b-col>
<b-col cols="2" class="justify-content-start align-items-center d-flex d-md-none pl-0 pr-0" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
v-if="!settings.left_handed">
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0"
variant="link">
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
:class="showDetails ? 'rotated' : ''"></i></div>
<b-button variant="success" block @click="useShoppingListStore().createObject({ amount: 0, unit: null, food: food, })"> {{ $t("Add") }}</b-button>
<b-button variant="warning" block @click="detail_modal_visible = false; setFoodIgnoredAndChecked(food)"> {{ $t("Ignore_Shopping") }}</b-button>
<b-button variant="danger" block class="mt-2"
@click="detail_modal_visible = false;useShoppingListStore().deleteEntries(entries)">
{{ $t('Delete_All') }}
</b-button>
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
</b-row>
<b-row align-h="center" class="d-none d-md-flex">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
<!-- detail rows -->
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
<div v-for="(e, x) in entries" :key="e.id">
<b-row class="small justify-content-around">
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link btn-sm m-0 p-0 pl-2"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)"
>
{{ formatOneRecipe(e) }}
</button>
</b-col>
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row align-h="start">
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
v-if="settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
<b-col cols="2" md="1" class="align-items-center d-flex">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
@click.stop="$emit('open-context-menu', $event, e)">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
:class="settings.left_handed ? 'dropdown-spacing' : ''"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 pr-md-3 pl-md-3 dropdown-toggle-no-caret"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</b-col>
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
<input type="checkbox" class="form-control form-control-sm checkbox-control"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
<b-col cols="7" md="9">
<b-row class="d-flex align-items-center h-100">
<b-col cols="5" md="3" class="d-flex align-items-center">
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
</b-col>
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
{{ formatOneFood(e) }}
</b-col>
<b-col cols="12" class="d-flex d-md-none">
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)"
:key="i">{{ n }}
</div>
</b-col>
</b-row>
</b-col>
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
v-if="!settings.left_handed">
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
</b-col>
</b-row>
<hr class="w-75 mt-1 mb-1 mt-md-3 mb-md-3" v-if="x !== entries.length - 1"/>
<div class="pb-1 pb-md-4" v-if="x === entries.length - 1"></div>
</div>
</div>
<hr class="m-1" v-if="!showDetails"/>
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
<template #menu="{ contextData }" v-if="recipe">
<ContextMenuItem>
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
</ContextMenuItem>
<ContextMenuItem @click="$refs.menu.close()">
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
<template #label>
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i>
{{ $t("Servings") }}</a>
</template>
<div @click.prevent.stop>
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
</div>
</b-form-group>
</ContextMenuItem>
</template>
</ContextMenu>
<i class="fa fa-hourglass fa-lg" style="display: none; position: absolute" aria-hidden="true"
ref="delay_icon"></i>
<i class="fa fa-check fa-lg" style="display: none; position: absolute" aria-hidden="true" ref="check_icon"></i>
<template #modal-footer>
<span></span>
</template>
</b-modal>
<generic-modal-form :model="Models.FOOD" :show="editing_food !== null"
@hidden="editing_food = null; useShoppingListStore().refreshFromAPI()"></generic-modal-form>
</div>
</template>
@@ -178,292 +136,237 @@
import Vue from "vue"
import {BootstrapVue} from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import ContextMenu from "@/components/ContextMenu/ContextMenu"
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
import {ApiMixin} from "@/utils/utils"
import RecipeCard from "./RecipeCard.vue"
import Vue2TouchEvents from "vue2-touch-events"
import {ApiMixin, FormatMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils"
import {useMealPlanStore} from "@/stores/MealPlanStore";
import {useShoppingListStore} from "@/stores/ShoppingListStore";
import {ApiApiFactory} from "@/utils/openapi/api";
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import NumberScalerComponent from "@/components/NumberScalerComponent.vue";
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
import GenericMultiselect from "@/components/GenericMultiselect.vue";
Vue.use(BootstrapVue)
Vue.use(Vue2TouchEvents)
export default {
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
// or i'm capturing it incorrectly
name: "ShoppingLineItem",
mixins: [ApiMixin],
components: {RecipeCard, ContextMenu, ContextMenuItem},
mixins: [ApiMixin, FormatMixin],
components: {GenericMultiselect, GenericModalForm, NumberScalerComponent},
props: {
entries: {
type: Array,
},
settings: Object,
groupby: {type: String},
entries: {type: Object,},
},
data() {
return {
showDetails: false,
recipe: undefined,
servings: 1,
dragStartX: 0,
distance_left: 0
detail_modal_visible: false,
editing_food: null,
}
},
computed: {
formatAmount: function () {
let amount = {}
this.entries.forEach((entry) => {
let unit = entry?.unit?.name ?? "---"
if (entry.amount) {
if (amount[unit]) {
amount[unit] += entry.amount
} else {
amount[unit] = entry.amount
item_container_id: function () {
let id = 'id_sli_'
for (let i in this.entries) {
id += i + '_'
}
return id
},
is_checked: function () {
for (let i in this.entries) {
if (!this.entries[i].checked) {
return false
}
}
return true
},
is_delayed: function () {
for (let i in this.entries) {
if (Date.parse(this.entries[i].delay_until) > new Date(Date.now())) {
return true
}
}
return false
},
food: function () {
return this.entries[Object.keys(this.entries)[0]]['food']
},
amounts: function () {
let unit_amounts = {}
for (let i in this.entries) {
let e = this.entries[i]
if (!e.checked && e.delay_until === null
|| (e.checked && useUserPreferenceStore().device_settings.shopping_show_checked_entries)
|| (e.delay_until !== null && useUserPreferenceStore().device_settings.shopping_show_delayed_entries)) {
let unit = -1
if (e.unit !== undefined && e.unit !== null) {
unit = e.unit.id
}
if (e.amount > 0) {
if (unit in unit_amounts) {
unit_amounts[unit]['amount'] += e.amount
} else {
if (unit === -1) {
unit_amounts[unit] = {id: -1, unit: "", amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
} else {
unit_amounts[unit] = {id: e.unit.id, unit: e.unit.name, amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
}
}
}
}
})
for (const [k, v] of Object.entries(amount)) {
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
}
return amount
return unit_amounts
},
formatCategory: function () {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
food_row: function () {
return this.food.name
},
formatChecked: function () {
return this.entries.map((x) => x.checked).every((x) => x === true)
},
formatHint: function () {
if (this.groupby == "recipe") {
return this.formatCategory
} else {
return this.formatRecipe
}
},
formatFood: function () {
return this.formatOneFood(this.entries[0])
},
formatUnit: function () {
return this.formatOneUnit(this.entries[0])
},
formatRecipe: function () {
if (this.entries?.length == 1) {
return this.formatOneMealPlan(this.entries[0]) || ""
} else {
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
info_row: function () {
let info_row = []
return mealplan_name
.map((x) => {
return this.formatOneMealPlan(x)
})
.join(" - ")
let authors = []
let recipes = []
let meal_pans = []
for (let i in this.entries) {
let e = this.entries[i]
if (authors.indexOf(e.created_by.display_name) === -1) {
authors.push(e.created_by.display_name)
}
if (e.recipe_mealplan !== null) {
let recipe_name = e.recipe_mealplan.recipe_name
if (recipes.indexOf(recipe_name) === -1) {
recipes.push(recipe_name.substring(0, 14) + (recipe_name.length > 14 ? '..' : ''))
}
if ('mealplan_from_date' in e.recipe_mealplan) {
let meal_plan_entry = (e?.recipe_mealplan?.mealplan_type || '') + ' (' + this.formatDate(e.recipe_mealplan.mealplan_from_date) + ')'
if (meal_pans.indexOf(meal_plan_entry) === -1) {
meal_pans.push(meal_plan_entry)
}
}
}
}
},
formatNotes: function () {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
if (useUserPreferenceStore().device_settings.shopping_item_info_created_by && authors.length > 0) {
info_row.push(authors.join(', '))
}
return ""
},
if (useUserPreferenceStore().device_settings.shopping_item_info_recipe && recipes.length > 0) {
info_row.push(recipes.join(', '))
}
if (useUserPreferenceStore().device_settings.shopping_item_info_mealplan && meal_pans.length > 0) {
info_row.push(meal_pans.join(', '))
}
return info_row.join(' - ')
}
},
watch: {},
mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
methods: {
// this.genericAPI inherited from ApiMixin
formatDate: function (datetime) {
if (!datetime) {
return
useUserPreferenceStore,
useShoppingListStore,
resolveDjangoUrl,
/**
* update the food after the category was changed
* handle changing category to category ID as a workaround
* @param food
*/
updateFoodCategory: function (food) {
if (typeof food.supermarket_category === "number") { // not the best solution, but as long as generic multiselect does not support caching, I don't want to use a proper model
food.supermarket_category = this.useShoppingListStore().supermarket_categories.filter(sc => sc.id === food.supermarket_category)[0]
}
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
timeStyle: "short",
}).format(Date.parse(datetime))
},
startHandler: function (event) {
if (event.changedTouches.length > 0) {
this.dragStartX = event.changedTouches[0].clientX
}
},
getOffset(el) {
let rect = el.getBoundingClientRect();
return {
left: rect.left + window.scrollX,
top: rect.top + window.scrollY,
right: rect.right - window.scrollX,
};
},
moveHandler: function (event) {
let item = this.$refs['shopping_line_item'];
this.distance_left = event.changedTouches[0].clientX - this.dragStartX;
item.style.marginLeft = this.distance_left
item.style.marginRight = -this.distance_left
item.style.backgroundColor = '#ddbf86'
item.style.border = "1px solid #000"
let delay_icon = this.$refs['delay_icon']
let check_icon = this.$refs['check_icon']
let apiClient = new ApiApiFactory()
apiClient.updateFood(food.id, food).then(r => {
let color_factor = Math.abs(this.distance_left) / 100
if (this.distance_left > 0) {
item.parentElement.parentElement.style.backgroundColor = 'rgba(130,170,139,0)'.replace(/[^,]+(?=\))/, color_factor)
check_icon.style.display = "block"
check_icon.style.left = this.getOffset(item.parentElement.parentElement).left + 40
check_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
check_icon.style.opacity = color_factor - 0.3
} else {
item.parentElement.parentElement.style.backgroundColor = 'rgba(185,135,102,0)'.replace(/[^,]+(?=\))/, color_factor)
delay_icon.style.display = "block"
delay_icon.style.left = this.getOffset(item.parentElement.parentElement).right - 40
delay_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
delay_icon.style.opacity = color_factor - 0.3
}
},
endHandler: function (event) {
let item = this.$refs['shopping_line_item'];
item.removeAttribute('style');
item.parentElement.parentElement.removeAttribute('style');
let delay_icon = this.$refs['delay_icon']
let check_icon = this.$refs['check_icon']
delay_icon.style.display = "none"
check_icon.style.display = "none"
if (Math.abs(this.distance_left) > window.screen.width / 6) {
if (this.distance_left > 0) {
let checked = false;
this.entries.forEach((cur) => {
checked = cur.checked
})
let update = {entries: this.entries.map((x) => x.id), checked: !checked}
this.$emit("update-checkbox", update)
} else {
this.$emit("update-delaythis", this.entries)
}
}
},
formatOneAmount: function (item) {
return item?.amount ?? 1
},
formatOneUnit: function (item) {
return item?.unit?.name ?? ""
},
formatOneCategory: function (item) {
return item?.food?.supermarket_category?.name
},
formatOneCompletedAt: function (item) {
if (!item.completed_at) {
return false
}
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
},
formatOneFood: function (item) {
return item.food.name
},
formatOneDelayUntil: function (item) {
if (!item.delay_until || (item.delay_until && item.checked)) {
return false
}
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
},
formatOneMealPlan: function (item) {
return item?.recipe_mealplan?.name ?? ""
},
formatOneRecipe: function (item) {
return item?.recipe_mealplan?.recipe_name ?? ""
},
formatOneNote: function (item) {
if (!item) {
item = this.entries[0]
}
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
},
formatOneCreatedBy: function (item) {
return [this.$t("Added_by"), item?.created_by.display_name, "@", this.formatDate(item.created_at)].join(" ")
},
openRecipeCard: function (e, item) {
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, {id: item.recipe_mealplan.recipe}).then((result) => {
let recipe = result.data
recipe.steps = undefined
this.recipe = true
this.$refs.recipe_card.open(e, recipe)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
updateChecked: function (e, item) {
let update = undefined
if (!item) {
update = {entries: this.entries.map((x) => x.id), checked: !this.formatChecked}
} else {
update = {entries: [item], checked: !item.checked}
}
console.log(update)
this.$emit("update-checkbox", update)
/**
* set food on_hand status to true and check all associated entries
* @param food
*/
setFoodIgnoredAndChecked: function (food) {
let apiClient = new ApiApiFactory()
food.ignore_shopping = true
apiClient.updateFood(food.id, food).then(r => {
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
useShoppingListStore().setEntriesCheckedState(this.entries, true, false)
},
/**
* function triggered by touchend event of swipe container
* check if min distance is reached and execute desired action
*/
handleSwipe: function () {
const minDistance = 80;
const container = document.querySelector('#' + this.item_container_id);
// get the distance the user swiped
const swipeDistance = container.scrollLeft - container.clientWidth;
if (swipeDistance < minDistance * -1) {
useShoppingListStore().setEntriesCheckedState(this.entries, !this.is_checked, true)
} else if (swipeDistance > minDistance) {
useShoppingListStore().delayEntries(this.entries, !this.is_delayed, true)
}
}
},
}
</script>
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
<style>
/* table { border-collapse:collapse } /* Ensure no space between cells */
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
/* tr.strikeout td:before { /* Create a new element that */
/* content: " "; /* …has no text content */
/* position: absolute; /* …is absolutely positioned */
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
/* border-bottom: 1px solid #000; /* …and with a border on the top */
/* } */
.checkbox-control {
font-size: 0.6rem;
/* scroll snap takes care of restoring scroll position */
.swipe-container {
display: flex;
overflow: auto;
overflow-x: scroll;
scroll-snap-type: x mandatory;
}
.checkbox-control-mobile {
font-size: 1rem;
/* scrollbar should be hidden */
.swipe-container::-webkit-scrollbar {
display: none;
}
.rotate {
-moz-transition: all 0.25s linear;
-webkit-transition: all 0.25s linear;
transition: all 0.25s linear;
.swipe-container {
scrollbar-width: none; /* For Firefox */
}
.rotated {
-moz-transform: rotate(90deg);
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
/* main element should always snap into view */
.swipe-element {
scroll-snap-align: start;
}
.unit-badge-lg {
font-size: 1rem !important;
font-weight: 500 !important;
.swipe-icon {
color: white;
position: sticky;
left: 16px;
right: 16px;
}
@media (max-width: 768px) {
.dropdown-spacing {
padding-left: 0 !important;
padding-right: 0 !important;
}
/* swipe-actions and element should be 100% wide */
.swipe-action,
.swipe-element {
min-width: 100%;
}
.invis-border {
border: 1px solid transparent;
.swipe-action {
display: flex;
align-items: center;
}
@media (min-width: 992px) {
.fa-ellipsis-v {
font-size: 20px;
}
.right {
justify-content: flex-end;
}
@media (min-width: 576px) {
.fa-ellipsis-v {
font-size: 16px;
}
}
</style>

View File

@@ -158,7 +158,7 @@
"Create_New_Unit": "Neue Einheit hinzufügen",
"Instructions": "Anleitung",
"Time": "Zeit",
"New_Keyword": "Neues Schlagwort",
"New_Keyword": "Neues Stichwort",
"Delete_Keyword": "Schlagwort löschen",
"show_split_screen": "Geteilte Ansicht",
"Recipes_per_page": "Rezepte pro Seite",
@@ -555,5 +555,13 @@
"FDC_ID_help": "FDC Datenbank ID",
"CustomImageHelp": "Laden Sie ein Bild hoch, das in der Space-Übersicht angezeigt werden soll.",
"CustomNavLogoHelp": "Laden Sie ein Bild hoch, das als Logo für die Navigationsleiste verwendet werden soll.",
"CustomLogos": "Individuelle Logos"
"CustomLogos": "Individuelle Logos",
"Input": "Eingabe",
"Undo": "Rückgängig",
"NoMoreUndo": "Rückgängig: Keine Änderungen",
"created_by": "Erstellt von",
"ShoppingBackgroundSyncWarning": "Schlechte Netzwerkverbindung, Warten auf Synchronisation ...",
"ShowRecentlyCompleted": "Zuletzt abgehakte Zutaten zeigen",
"Enable": "Aktivieren",
"Delete_All": "Alles löschen"
}

View File

@@ -96,6 +96,9 @@
"base_unit": "Base Unit",
"base_amount": "Base Amount",
"Datatype": "Datatype",
"Input": "Input",
"Undo": "Undo",
"NoMoreUndo": "No changes to be undone.",
"Number of Objects": "Number of Objects",
"Add_Step": "Add Step",
"Keywords": "Keywords",
@@ -141,6 +144,7 @@
"Edit": "Edit",
"Image": "Image",
"Delete": "Delete",
"Delete_All": "Delete all",
"Open": "Open",
"Ok": "Ok",
"Save": "Save",
@@ -239,6 +243,7 @@
"Week": "Week",
"Month": "Month",
"Year": "Year",
"created_by": "Created by",
"Planner": "Planner",
"Planner_Settings": "Planner settings",
"Period": "Period",
@@ -295,9 +300,11 @@
"Warning": "Warning",
"NoCategory": "No category selected.",
"InheritWarning": "{food} is set to inherit, changes may not persist.",
"ShowDelayed": "Show Delayed Items",
"ShowDelayed": "Show delayed items",
"ShowRecentlyCompleted": "Show recently completed items",
"Completed": "Completed",
"OfflineAlert": "You are offline, shopping list may not syncronize.",
"ShoppingBackgroundSyncWarning": "Bad network, waiting to sync ...",
"shopping_share": "Share Shopping List",
"shopping_auto_sync": "Autosync",
"one_url_per_line": "One URL per line",
@@ -496,6 +503,7 @@
"Reset": "Reset",
"Disabled": "Disabled",
"Disable": "Disable",
"Enable": "Enable",
"Options": "Options",
"Create Food": "Create Food",
"create_food_desc": "Create a food and link it to this recipe.",

View File

@@ -554,5 +554,13 @@
"CustomLogos": "Własne loga",
"Show_Logo": "Pokaż logo",
"Nav_Text_Mode": "Tryb nawigacji tekstowej",
"Nav_Text_Mode_Help": "Zachowuje się inaczej dla każdego motywu."
"Nav_Text_Mode_Help": "Zachowuje się inaczej dla każdego motywu.",
"Input": "Wprowadź",
"Undo": "Cofnij",
"NoMoreUndo": "Brak zmian do wycofania.",
"Delete_All": "Usuń wszystko",
"ShowRecentlyCompleted": "Pokaż ostatnio zakończone elementy",
"ShoppingBackgroundSyncWarning": "Słaba sieć, oczekiwanie na synchronizację...",
"Enable": "Włączyć",
"created_by": "Stworzone przez"
}

View File

@@ -480,5 +480,83 @@
"Create Recipe": "创建食谱",
"Import Recipe": "导入食谱",
"recipe_property_info": "您也可以为食物添加属性,以便根据食谱自动计算!",
"per_serving": "每份"
"per_serving": "每份",
"converted_amount": "换算量",
"Open_Data_Import": "开放数据导入",
"StartDate": "开始日期",
"EndDate": "结束日期",
"OrderInformation": "对象按照从小到大的顺序排列。",
"total": "全部",
"pound": "磅(重量)",
"imperial_quart": "英制夸脱【imp qt】英制体积",
"imperial_pint": "英制品脱【imp pt】英制体积",
"imperial_tbsp": "英制汤匙【imp tbsp】英制体积",
"make_now_count": "至多缺少的成分",
"l": "升【l】公制体积",
"Welcome": "欢迎",
"Input": "输入",
"Undo": "撤销",
"Number of Objects": "对象数量",
"Alignment": "校准",
"Delete_All": "全部删除",
"Conversion": "转换",
"Properties": "属性",
"ShowRecentlyCompleted": "显示最近完成的项目",
"ShoppingBackgroundSyncWarning": "网络状况不佳,正在等待进行同步……",
"show_step_ingredients_setting": "在食谱步骤旁边显示成分",
"show_step_ingredients_setting_help": "在食谱步骤旁边添加成分表。在创建时应用。可以在编辑配方视图中覆盖。",
"show_step_ingredients": "显示该步骤的成分",
"hide_step_ingredients": "隐藏该步骤的成分",
"Logo": "徽标",
"Show_Logo": "显示徽标",
"Show_Logo_Help": "在导航栏中显示 Tandoor 或空间徽标。",
"Nav_Text_Mode": "文本导航模式",
"Nav_Text_Mode_Help": "每个主题的行为都不同。",
"Space_Cosmetic_Settings": "空间管理员可以更改某些装饰设置,并将覆盖该空间的客户端设置。",
"show_ingredients_table": "在步骤文本旁边显示成分表",
"Enable": "启用",
"g": "克【g】公制重量",
"gallon": "加仑【gal】美制体积",
"tbsp": "汤匙【tbsp】美制体积",
"tsp": "茶匙【tsp】美制体积",
"imperial_gallon": "英制加仑【imp gal】英制体积",
"imperial_tsp": "英制茶匙【imp tsp】英制体积",
"Choose_Category": "选择类别",
"Back": "后退",
"Food_Replace": "食物替换",
"Unit_Replace": "单位替换",
"Datatype": "数据类型",
"NoMoreUndo": "没有可撤消的更改。",
"FDC_Search": "FDC搜索",
"FDC_ID_help": "FDC数据库ID",
"property_type_fdc_hint": "只有具有 FDC ID 的属性类型才能自动从 FDC 数据库中提取数据",
"Data_Import_Info": "通过导入社区精选的食物、单位等列表来增强您的空间,以提升您的食谱收藏。",
"Update_Existing_Data": "更新现有数据",
"Use_Metric": "使用公制单位",
"Learn_More": "了解更多",
"converted_unit": "换算单位",
"base_unit": "基本单位",
"base_amount": "基本量",
"Property": "属性",
"Property_Editor": "属性编辑器",
"imperial_fluid_ounce": "英制液体盎司【imp fl oz】英制体积",
"kg": "千克【kg】公制重量",
"ounce": "盎司【oz】重量",
"ml": "毫升【ml】公制体积",
"fluid_ounce": "液体盎司【fl oz】美制体积",
"pint": "品脱 【pt】美制体积",
"quart": "夸脱【qt】美制体积",
"Name_Replace": "名称替换",
"FDC_ID": "FDC ID",
"err_importing_recipe": "导入菜谱时出错!",
"open_data_help_text": "Tandoor开放数据项目为Tandoor提供社区贡献的数据。该字段在导入时会自动填充并可以之后更新。",
"Open_Data_Slug": "开放数据标识",
"Properties_Food_Amount": "食物数量属性",
"Properties_Food_Unit": "食品单位属性",
"CustomTheme": "自定义主题",
"CustomThemeHelp": "通过上传自定义 CSS 文件覆盖所选主题的样式。",
"CustomImageHelp": "上传图片以在空间概览中显示。",
"CustomNavLogoHelp": "上传图像以用作导航栏徽标。",
"CustomLogoHelp": "上传不同尺寸的方形图像以更改为浏览器选项卡和安装的网络应用程序中的徽标。",
"CustomLogos": "自定义徽标"
}

View File

@@ -47,7 +47,7 @@ export const useMealPlanStore = defineStore(_STORE_ID, {
},
actions: {
refreshFromAPI(from_date, to_date) {
if (this.currently_updating !== [from_date, to_date]) {
if (this.currently_updating != null && (this.currently_updating[0] !== from_date || this.currently_updating[1] !== to_date)) {
this.currently_updating = [from_date, to_date] // certainly no perfect check but better than nothing
let apiClient = new ApiApiFactory()
@@ -102,7 +102,7 @@ export const useMealPlanStore = defineStore(_STORE_ID, {
},
loadClientSettings() {
let s = localStorage.getItem(_LOCAL_STORAGE_KEY)
if (s === null || s === {}) {
if (s === null) {
return {
displayPeriodUom: "week",
displayPeriodCount: 3,

View File

@@ -0,0 +1,491 @@
import {ApiApiFactory} from "@/utils/openapi/api"
import {StandardToasts} from "@/utils/utils"
import {defineStore} from "pinia"
import Vue from "vue"
import _ from 'lodash';
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
import moment from "moment/moment";
const _STORE_ID = "shopping_list_store"
/*
* test store to play around with pinia and see if it can work for my use cases
* don't trust that all shopping list entries are in store as there is no cache validation logic, its just a shared data holder
* */
export const useShoppingListStore = defineStore(_STORE_ID, {
state: () => ({
// shopping data
entries: {},
supermarket_categories: [],
supermarkets: [],
total_unchecked: 0,
total_checked: 0,
total_unchecked_food: 0,
total_checked_food: 0,
// internal
currently_updating: false,
last_autosync: null,
autosync_has_focus: true,
autosync_timeout_id: null,
undo_stack: [],
queue_timeout_id: undefined,
item_check_sync_queue: {},
// constants
GROUP_CATEGORY: 'food.supermarket_category.name',
GROUP_CREATED_BY: 'created_by.display_name',
GROUP_RECIPE: 'recipe_mealplan.recipe_name',
UNDEFINED_CATEGORY: 'shopping_undefined_category'
}),
getters: {
/**
* build a multi-level data structure ready for display from shopping list entries
* group by selected grouping key
* @return {{}}
*/
get_entries_by_group: function () {
let structure = {}
let ordered_structure = []
// build structure
for (let i in this.entries) {
structure = this.updateEntryInStructure(structure, this.entries[i], useUserPreferenceStore().device_settings.shopping_selected_grouping)
}
// statistics for UI conditions and display
let total_unchecked = 0
let total_checked = 0
let total_unchecked_food = 0
let total_checked_food = 0
for (let i in structure) {
let count_unchecked = 0
let count_checked = 0
let count_unchecked_food = 0
let count_checked_food = 0
for (let fi in structure[i]['foods']) {
let food_checked = true
for (let ei in structure[i]['foods'][fi]['entries']) {
if (structure[i]['foods'][fi]['entries'][ei].checked) {
count_checked++
} else {
food_checked = false
count_unchecked++
}
}
if (food_checked) {
count_checked_food++
} else {
count_unchecked_food++
}
}
Vue.set(structure[i], 'count_unchecked', count_unchecked)
Vue.set(structure[i], 'count_checked', count_checked)
Vue.set(structure[i], 'count_unchecked_food', count_unchecked_food)
Vue.set(structure[i], 'count_checked_food', count_checked_food)
total_unchecked += count_unchecked
total_checked += count_checked
total_unchecked_food += count_unchecked_food
total_checked_food += count_checked_food
}
this.total_unchecked = total_unchecked
this.total_checked = total_checked
this.total_unchecked_food = total_unchecked_food
this.total_checked_food = total_checked_food
// ordering
if (this.UNDEFINED_CATEGORY in structure) {
ordered_structure.push(structure[this.UNDEFINED_CATEGORY])
Vue.delete(structure, this.UNDEFINED_CATEGORY)
}
if (useUserPreferenceStore().device_settings.shopping_selected_grouping === this.GROUP_CATEGORY && useUserPreferenceStore().device_settings.shopping_selected_supermarket !== null) {
for (let c of useUserPreferenceStore().device_settings.shopping_selected_supermarket.category_to_supermarket) {
if (c.category.name in structure) {
ordered_structure.push(structure[c.category.name])
Vue.delete(structure, c.category.name)
}
}
if (!useUserPreferenceStore().device_settings.shopping_show_selected_supermarket_only) {
for (let i in structure) {
ordered_structure.push(structure[i])
}
}
} else {
for (let i in structure) {
ordered_structure.push(structure[i])
}
}
return ordered_structure
},
/**
* flattened list of entries used for exporters
* kinda uncool but works for now
* @return {*[]}
*/
get_flat_entries: function () {
let items = []
for (let i in this.get_entries_by_group) {
for (let f in this.get_entries_by_group[i]['foods']) {
for (let e in this.get_entries_by_group[i]['foods'][f]['entries']) {
items.push({
amount: this.get_entries_by_group[i]['foods'][f]['entries'][e].amount,
unit: this.get_entries_by_group[i]['foods'][f]['entries'][e].unit?.name ?? '',
food: this.get_entries_by_group[i]['foods'][f]['entries'][e].food?.name ?? '',
})
}
}
}
return items
},
/**
* list of options available for grouping entry display
* @return {[{id: *, translatable_label: string},{id: *, translatable_label: string},{id: *, translatable_label: string}]}
*/
grouping_options: function () {
return [
{'id': this.GROUP_CATEGORY, 'translatable_label': 'Category'},
{'id': this.GROUP_CREATED_BY, 'translatable_label': 'created_by'},
{'id': this.GROUP_RECIPE, 'translatable_label': 'Recipe'}
]
},
/**
* checks if failed items are contained in the sync queue
*/
has_failed_items: function () {
for (let i in this.item_check_sync_queue) {
if (this.item_check_sync_queue[i]['status'] === 'syncing_failed_before' || this.item_check_sync_queue[i]['status'] === 'waiting_failed_before') {
return true
}
}
return false
}
},
actions: {
/**
* Retrieves all shopping related data (shopping list entries, supermarkets, supermarket categories and shopping list recipes) from API
*/
refreshFromAPI() {
if (!this.currently_updating) {
this.currently_updating = true
this.last_autosync = new Date().getTime();
let apiClient = new ApiApiFactory()
apiClient.listShoppingListEntrys().then((r) => {
this.entries = {}
r.data.forEach((e) => {
Vue.set(this.entries, e.id, e)
})
this.currently_updating = false
}).catch((err) => {
this.currently_updating = false
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
apiClient.listSupermarketCategorys().then(r => {
this.supermarket_categories = r.data
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
apiClient.listSupermarkets().then(r => {
this.supermarkets = r.data
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
})
}
},
/**
* perform auto sync request to special endpoint returning only entries changed since last auto sync
* only updates local entries that are older than the server version
*/
autosync() {
if (!this.currently_updating && this.autosync_has_focus) {
console.log('running autosync')
this.currently_updating = true
let previous_autosync = this.last_autosync
this.last_autosync = new Date().getTime();
let apiClient = new ApiApiFactory()
apiClient.listShoppingListEntrys(undefined, undefined, undefined, {
'query': {'last_autosync': previous_autosync}
}).then((r) => {
r.data.forEach((e) => {
// dont update stale client data
if (!(Object.keys(this.entries).includes(e.id.toString())) || Date.parse(this.entries[e.id].updated_at) < Date.parse(e.updated_at)) {
console.log('auto sync updating entry ', e)
Vue.set(this.entries, e.id, e)
}
})
this.currently_updating = false
}).catch((err) => {
console.warn('auto sync failed')
this.currently_updating = false
})
}
},
/**
* Create a new shopping list entry
* adds new entry to store
* @param object entry object to create
* @return {Promise<T | void>} promise of creation call to subscribe to
*/
createObject(object) {
let apiClient = new ApiApiFactory()
return apiClient.createShoppingListEntry(object).then((r) => {
Vue.set(this.entries, r.data.id, r.data)
this.registerChange('CREATED', {[r.data.id]: r.data},)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
/**
* update existing entry object and updated_at timestamp
* updates data in store
* IMPORTANT: always use this method to update objects to keep client state consistent
* @param object entry object to update
* @return {Promise<T | void>} promise of updating call to subscribe to
*/
updateObject(object) {
let apiClient = new ApiApiFactory()
// sets the update_at timestamp on the client to prevent auto sync from overriding with older changes
// moment().format() yields locale aware datetime without ms 2024-01-04T13:39:08.607238+01:00
Vue.set(object, 'updated_at', moment().format())
return apiClient.updateShoppingListEntry(object.id, object).then((r) => {
Vue.set(this.entries, r.data.id, r.data)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
/**
* delete shopping list entry object from DB and store
* @param object entry object to delete
* @return {Promise<T | void>} promise of delete call to subscribe to
*/
deleteObject(object) {
let apiClient = new ApiApiFactory()
return apiClient.destroyShoppingListEntry(object.id).then((r) => {
Vue.delete(this.entries, object.id)
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
})
},
/**
* returns a distinct list of recipes associated with unchecked shopping list entries
*/
getAssociatedRecipes: function () {
let recipes = {}
for (let i in this.entries) {
let e = this.entries[i]
if (e.recipe_mealplan !== null) {
Vue.set(recipes, e.recipe_mealplan.recipe, {
'shopping_list_recipe_id': e.list_recipe,
'recipe_id': e.recipe_mealplan.recipe,
'recipe_name': e.recipe_mealplan.recipe_name,
'servings': e.recipe_mealplan.servings,
'mealplan_from_date': e.recipe_mealplan.mealplan_from_date,
'mealplan_type': e.recipe_mealplan.mealplan_type,
})
}
}
return recipes
},
// convenience methods
/**
* function to set entry to its proper place in the data structure to perform grouping
* @param {{}} structure datastructure
* @param {*} entry entry to place
* @param {*} group group to place entry into (must be of ShoppingListStore.GROUP_XXX/dot notation of entry property)
* @returns {{}} datastructure including entry
*/
updateEntryInStructure(structure, entry, group) {
let grouping_key = _.get(entry, group, this.UNDEFINED_CATEGORY)
if (grouping_key === undefined || grouping_key === null) {
grouping_key = this.UNDEFINED_CATEGORY
}
if (!(grouping_key in structure)) {
Vue.set(structure, grouping_key, {'name': grouping_key, 'foods': {}})
}
if (!(entry.food.id in structure[grouping_key]['foods'])) {
Vue.set(structure[grouping_key]['foods'], entry.food.id, {
'id': entry.food.id,
'name': entry.food.name,
'entries': {}
})
}
Vue.set(structure[grouping_key]['foods'][entry.food.id]['entries'], entry.id, entry)
return structure
},
/**
* function to handle user checking or unchecking a set of entries
* @param {{}} entries set of entries
* @param checked boolean to set checked state of entry to
* @param undo if the user should be able to undo the change or not
*/
setEntriesCheckedState(entries, checked, undo) {
if (undo) {
this.registerChange((checked ? 'CHECKED' : 'UNCHECKED'), entries)
}
let entry_id_list = []
for (let i in entries) {
Vue.set(this.entries[i], 'checked', checked)
Vue.set(this.entries[i], 'updated_at', moment().format())
entry_id_list.push(i)
}
this.item_check_sync_queue
Vue.set(this.item_check_sync_queue, Math.random(), {
'ids': entry_id_list,
'checked': checked,
'status': 'waiting'
})
this.runSyncQueue(5)
},
/**
* go through the list of queued requests and try to run them
* add request back to queue if it fails due to offline or timeout
* Do NOT call this method directly, always call using runSyncQueue method to prevent simultaneous runs
* @private
*/
_replaySyncQueue() {
if (navigator.onLine || document.location.href.includes('localhost')) {
let apiClient = new ApiApiFactory()
let promises = []
for (let i in this.item_check_sync_queue) {
let entry = this.item_check_sync_queue[i]
Vue.set(entry, 'status', ((entry['status'] === 'waiting') ? 'syncing' : 'syncing_failed_before'))
Vue.set(this.item_check_sync_queue, i, entry)
let p = apiClient.bulkShoppingListEntry(entry, {timeout: 15000}).then((r) => {
Vue.delete(this.item_check_sync_queue, i)
}).catch((err) => {
if (err.code === "ERR_NETWORK" || err.code === "ECONNABORTED") {
Vue.set(entry, 'status', 'waiting_failed_before')
Vue.set(this.item_check_sync_queue, i, entry)
} else {
Vue.delete(this.item_check_sync_queue, i)
console.error('Failed API call for entry ', entry)
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
}
})
promises.push(p)
}
Promise.allSettled(promises).finally(r => {
this.runSyncQueue(500)
})
} else {
this.runSyncQueue(5000)
}
},
/**
* manages running the replaySyncQueue function after the given timeout
* calling this function might cancel a previously created timeout
* @param timeout time in ms after which to run the replaySyncQueue function
*/
runSyncQueue(timeout) {
clearTimeout(this.queue_timeout_id)
this.queue_timeout_id = setTimeout(() => {
this._replaySyncQueue()
}, timeout)
},
/**
* function to handle user "delaying" and "undelaying" shopping entries
* @param {{}} entries set of entries
* @param delay if entries should be delayed or if delay should be removed
* @param undo if the user should be able to undo the change or not
*/
delayEntries(entries, delay, undo) {
let delay_hours = useUserPreferenceStore().user_settings.default_delay
let delay_date = new Date(Date.now() + delay_hours * (60 * 60 * 1000))
if (undo) {
this.registerChange((delay ? 'DELAY' : 'UNDELAY'), entries)
}
for (let i in entries) {
this.entries[i].delay_until = (delay ? delay_date : null)
this.updateObject(this.entries[i])
}
},
/**
* delete list of entries
* @param {{}} entries set of entries
*/
deleteEntries(entries) {
for (let i in entries) {
this.deleteObject(this.entries[i])
}
},
deleteShoppingListRecipe(shopping_list_recipe_id) {
let api = new ApiApiFactory()
for (let i in this.entries) {
if (this.entries[i].list_recipe === shopping_list_recipe_id) {
Vue.delete(this.entries, i)
}
}
api.destroyShoppingListRecipe(shopping_list_recipe_id).then((x) => {
// no need to update anything, entries were already removed
}).catch((err) => {
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_DELETE, err)
})
},
/**
* register the change to a set of entries to allow undoing it
* throws an Error if the operation type is not known
* @param type the type of change to register. This determines what undoing the change does. (CREATE->delete object,
* CHECKED->uncheck entry, UNCHECKED->check entry, DELAY->remove delay)
* @param {{}} entries set of entries
*/
registerChange(type, entries) {
if (!['CREATED', 'CHECKED', 'UNCHECKED', 'DELAY', 'UNDELAY'].includes(type)) {
throw Error('Tried to register unknown change type')
}
this.undo_stack.push({'type': type, 'entries': entries})
},
/**
* takes the last item from the undo stack and reverts it
*/
undoChange() {
let last_item = this.undo_stack.pop()
if (last_item !== undefined) {
let type = last_item['type']
let entries = last_item['entries']
if (type === 'CHECKED' || type === 'UNCHECKED') {
this.setEntriesCheckedState(entries, (type === 'UNCHECKED'), false)
} else if (type === 'DELAY' || type === 'UNDELAY') {
this.delayEntries(entries, (type === 'UNDELAY'), false)
} else if (type === 'CREATED') {
for (let i in entries) {
let e = entries[i]
this.deleteObject(e)
}
}
} else {
// can use localization in store
//StandardToasts.makeStandardToast(this, this.$t('NoMoreUndo'))
}
}
},
})

View File

@@ -1,20 +1,136 @@
import {defineStore} from 'pinia'
import {ApiApiFactory} from "@/utils/openapi/api";
import {ApiApiFactory, UserPreference} from "@/utils/openapi/api";
import Vue from "vue";
import {StandardToasts} from "@/utils/utils";
const _STALE_TIME_IN_MS = 1000 * 30
const _STORE_ID = 'user_preference_store'
const _LS_DEVICE_SETTINGS = 'TANDOOR_LOCAL_SETTINGS'
const _LS_USER_SETTINGS = 'TANDOOR_USER_SETTINGS'
const _USER_ID = localStorage.getItem('USER_ID')
export const useUserPreferenceStore = defineStore(_STORE_ID, {
state: () => ({
data: null,
updated_at: null,
currently_updating: false,
}),
getters: {
},
user_settings_loaded_at: new Date(0),
user_settings: {
image: null,
theme: "TANDOOR",
nav_bg_color: "#ddbf86",
nav_text_color: "DARK",
nav_show_logo: true,
default_unit: "g",
default_page: "SEARCH",
use_fractions: false,
use_kj: false,
plan_share: [],
nav_sticky: true,
ingredient_decimals: 2,
comments: true,
shopping_auto_sync: 5,
mealplan_autoadd_shopping: false,
food_inherit_default: [],
default_delay: "4.0000",
mealplan_autoinclude_related: true,
mealplan_autoexclude_onhand: true,
shopping_share: [],
shopping_recent_days: 7,
csv_delim: ",",
csv_prefix: "",
filter_to_supermarket: false,
shopping_add_onhand: false,
left_handed: false,
show_step_ingredients: true,
food_children_exist: false,
locally_updated_at: new Date(0),
},
device_settings_initialized: false,
device_settings_loaded_at: new Date(0),
device_settings: {
// shopping
shopping_show_checked_entries: false,
shopping_show_delayed_entries: false,
shopping_show_selected_supermarket_only: false,
shopping_selected_grouping: 'food.supermarket_category.name',
shopping_selected_supermarket: null,
shopping_item_info_created_by: false,
shopping_item_info_mealplan: false,
shopping_item_info_recipe: true,
},
}),
getters: {},
actions: {
// Device settings (on device settings stored in local storage)
/**
* Load device settings from local storage and update state device_settings
*/
loadDeviceSettings() {
let s = localStorage.getItem(_LS_DEVICE_SETTINGS)
if (s !== null) {
let settings = JSON.parse(s)
for (s in settings) {
Vue.set(this.device_settings, s, settings[s])
}
}
this.device_settings_initialized = true
},
/**
* persist changes to device settings into local storage
*/
updateDeviceSettings: function () {
localStorage.setItem(_LS_DEVICE_SETTINGS, JSON.stringify(this.device_settings))
},
// ---------------- new methods for user settings
loadUserSettings: function (allow_cached_results) {
let s = localStorage.getItem(_LS_USER_SETTINGS)
if (s !== null) {
let settings = JSON.parse(s)
for (s in settings) {
Vue.set(this.user_settings, s, settings[s])
}
console.log(`loaded local user settings age ${((new Date().getTime()) - this.user_settings.locally_updated_at) / 1000} `)
}
if (((new Date().getTime()) - this.user_settings.locally_updated_at) > _STALE_TIME_IN_MS || !allow_cached_results) {
console.log('refreshing user settings from API')
let apiClient = new ApiApiFactory()
apiClient.retrieveUserPreference(localStorage.getItem('USER_ID')).then(r => {
for (s in r.data) {
if (!(s in this.user_settings) && s !== 'user') {
// dont load new keys if no default exists (to prevent forgetting to add defaults)
console.error(`API returned UserPreference key "${s}" which has no default in UserPreferenceStore.user_settings.`)
} else {
Vue.set(this.user_settings, s, r.data[s])
}
}
Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime())
localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings))
}).catch(err => {
this.currently_updating = false
})
}
},
updateUserSettings: function () {
let apiClient = new ApiApiFactory()
apiClient.partialUpdateUserPreference(_USER_ID, this.user_settings).then(r => {
this.user_settings = r.data
Vue.set(this.user_settings, 'locally_updated_at', new Date().getTime())
localStorage.setItem(_LS_USER_SETTINGS, JSON.stringify(this.user_settings))
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
}).catch(err => {
this.currently_updating = false
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
})
},
// ----------------
// User Preferences (database settings stored in user preference model)
/**
* gets data from the store either directly or refreshes from API if data is considered stale
* @returns {UserPreference|*|Promise<axios.AxiosResponse<UserPreference>>}
@@ -69,12 +185,16 @@ export const useUserPreferenceStore = defineStore(_STORE_ID, {
*/
refreshFromAPI() {
let apiClient = new ApiApiFactory()
if(!this.currently_updating){
if (!this.currently_updating) {
this.currently_updating = true
return apiClient.retrieveUserPreference(localStorage.getItem('USER_ID')).then(r => {
this.data = r.data
this.updated_at = new Date()
this.currently_updating = false
this.user_settings = r.data
this.user_settings_loaded_at = new Date()
return this.data
}).catch(err => {
this.currently_updating = false

View File

@@ -408,6 +408,7 @@ export class Models {
static SHOPPING_CATEGORY = {
name: "Shopping_Category",
apiName: "SupermarketCategory",
merge: true,
create: {
params: [["name", "description"]],
form: {

View File

@@ -4013,18 +4013,6 @@ export interface ShoppingListEntries {
* @memberof ShoppingListEntries
*/
unit?: FoodPropertiesFoodUnit | null;
/**
*
* @type {number}
* @memberof ShoppingListEntries
*/
ingredient?: number | null;
/**
*
* @type {string}
* @memberof ShoppingListEntries
*/
ingredient_note?: string;
/**
*
* @type {string}
@@ -4061,6 +4049,12 @@ export interface ShoppingListEntries {
* @memberof ShoppingListEntries
*/
created_at?: string;
/**
*
* @type {string}
* @memberof ShoppingListEntries
*/
updated_at?: string;
/**
*
* @type {string}
@@ -4104,18 +4098,6 @@ export interface ShoppingListEntry {
* @memberof ShoppingListEntry
*/
unit?: FoodPropertiesFoodUnit | null;
/**
*
* @type {number}
* @memberof ShoppingListEntry
*/
ingredient?: number | null;
/**
*
* @type {string}
* @memberof ShoppingListEntry
*/
ingredient_note?: string;
/**
*
* @type {string}
@@ -4152,6 +4134,12 @@ export interface ShoppingListEntry {
* @memberof ShoppingListEntry
*/
created_at?: string;
/**
*
* @type {string}
* @memberof ShoppingListEntry
*/
updated_at?: string;
/**
*
* @type {string}
@@ -4165,6 +4153,25 @@ export interface ShoppingListEntry {
*/
delay_until?: string | null;
}
/**
*
* @export
* @interface ShoppingListEntryBulk
*/
export interface ShoppingListEntryBulk {
/**
*
* @type {Array<any>}
* @memberof ShoppingListEntryBulk
*/
ids: Array<any>;
/**
*
* @type {boolean}
* @memberof ShoppingListEntryBulk
*/
checked: boolean;
}
/**
*
* @export
@@ -4213,6 +4220,18 @@ export interface ShoppingListRecipe {
* @memberof ShoppingListRecipe
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipe
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipe
*/
mealplan_type?: string;
}
/**
*
@@ -4262,6 +4281,18 @@ export interface ShoppingListRecipeMealplan {
* @memberof ShoppingListRecipeMealplan
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipeMealplan
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipeMealplan
*/
mealplan_type?: string;
}
/**
*
@@ -4311,6 +4342,18 @@ export interface ShoppingListRecipes {
* @memberof ShoppingListRecipes
*/
mealplan_note?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipes
*/
mealplan_from_date?: string;
/**
*
* @type {string}
* @memberof ShoppingListRecipes
*/
mealplan_type?: string;
}
/**
*
@@ -4507,19 +4550,97 @@ export interface Space {
* @memberof Space
*/
nav_logo?: RecipeFile | null;
/**
*
* @type {string}
* @memberof Space
*/
space_theme?: SpaceSpaceThemeEnum;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
space_theme?: RecipeFile | null;
custom_space_theme?: RecipeFile | null;
/**
*
* @type {boolean}
* @type {string}
* @memberof Space
*/
use_plural?: boolean;
nav_bg_color?: string;
/**
*
* @type {string}
* @memberof Space
*/
nav_text_color?: SpaceNavTextColorEnum;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_32?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_128?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_144?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_180?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_192?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_512?: RecipeFile | null;
/**
*
* @type {RecipeFile}
* @memberof Space
*/
logo_color_svg?: RecipeFile | null;
}
/**
* @export
* @enum {string}
*/
export enum SpaceSpaceThemeEnum {
Blank = 'BLANK',
Tandoor = 'TANDOOR',
Bootstrap = 'BOOTSTRAP',
Darkly = 'DARKLY',
Flatly = 'FLATLY',
Superhero = 'SUPERHERO',
TandoorDark = 'TANDOOR_DARK'
}
/**
* @export
* @enum {string}
*/
export enum SpaceNavTextColorEnum {
Blank = 'BLANK',
Light = 'LIGHT',
Dark = 'DARK'
}
/**
*
* @export
@@ -5382,6 +5503,39 @@ export interface ViewLog {
*/
export const ApiApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkShoppingListEntry: async (shoppingListEntryBulk?: ShoppingListEntryBulk, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/shopping-list-entry/bulk/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(shoppingListEntryBulk, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {AccessToken} [accessToken]
@@ -9018,6 +9172,13 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarQueryParameter = {} as any;
if (options.order_field !== undefined) {
localVarQueryParameter['order_field'] = options.order_field;
}
if (options.order_direction!== undefined) {
localVarQueryParameter['order_direction'] = options.order_direction;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
@@ -9486,10 +9647,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSupermarkets: async (options: any = {}): Promise<RequestArgs> => {
listSupermarkets: async (query?: string, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/supermarket/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -9502,6 +9664,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
@@ -9661,10 +9827,11 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUserFiles: async (options: any = {}): Promise<RequestArgs> => {
listUserFiles: async (query?: string, options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/user-file/`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -9677,6 +9844,10 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
@@ -9935,6 +10106,47 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergeSupermarketCategory: async (id: string, target: string, supermarketCategory?: SupermarketCategory, options: any = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('mergeSupermarketCategory', 'id', id)
// verify required parameter 'target' is not null or undefined
assertParamExists('mergeSupermarketCategory', 'target', target)
const localVarPath = `/api/supermarket-category/{id}/merge/{target}/`
.replace(`{${"id"}}`, encodeURIComponent(String(id)))
.replace(`{${"target"}}`, encodeURIComponent(String(target)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(supermarketCategory, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@@ -10058,6 +10270,40 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying the book.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
partialUpdateManualOrderBooks: async (id: string, recipeBook?: RecipeBook , options: any = {}): Promise<RequestArgs> => {
const localVarPath = `/api/recipe-book/{id}/`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter, options.query);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(recipeBook , localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id A unique integer value identifying this access token.
@@ -14820,6 +15066,16 @@ export const ApiApiAxiosParamCreator = function (configuration?: Configuration)
export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ApiApiAxiosParamCreator(configuration)
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ShoppingListEntryBulk>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.bulkShoppingListEntry(shoppingListEntryBulk, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {AccessToken} [accessToken]
@@ -16034,11 +16290,12 @@ export const ApiApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listSupermarkets(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Supermarket>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarkets(options);
async listSupermarkets(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<Supermarket>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listSupermarkets(query, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -16085,11 +16342,12 @@ export const ApiApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listUserFiles(options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserFile>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUserFiles(options);
async listUserFiles(query?: string, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<UserFile>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUserFiles(query, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -16165,6 +16423,18 @@ export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergeKeyword(id, target, keyword, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SupermarketCategory>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergeSupermarketCategory(id, target, supermarketCategory, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@@ -16201,6 +16471,17 @@ export const ApiApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.moveKeyword(id, parent, keyword, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category relation.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async partialUpdateManualOrderBooks(id: string, recipeBook?: RecipeBook, options?: any): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SupermarketCategoryRelation>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.partialUpdateManualOrderBooks(id, recipeBook, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id A unique integer value identifying this access token.
@@ -17623,6 +17904,15 @@ export const ApiApiFp = function(configuration?: Configuration) {
export const ApiApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ApiApiFp(configuration)
return {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any): AxiosPromise<ShoppingListEntryBulk> {
return localVarFp.bulkShoppingListEntry(shoppingListEntryBulk, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AccessToken} [accessToken]
@@ -18719,11 +19009,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
},
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listSupermarkets(options?: any): AxiosPromise<Array<Supermarket>> {
return localVarFp.listSupermarkets(options).then((request) => request(axios, basePath));
listSupermarkets(query?: string, options?: any): AxiosPromise<Array<Supermarket>> {
return localVarFp.listSupermarkets(query, options).then((request) => request(axios, basePath));
},
/**
*
@@ -18765,11 +19056,12 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
},
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUserFiles(options?: any): AxiosPromise<Array<UserFile>> {
return localVarFp.listUserFiles(options).then((request) => request(axios, basePath));
listUserFiles(query?: string, options?: any): AxiosPromise<Array<UserFile>> {
return localVarFp.listUserFiles(query, options).then((request) => request(axios, basePath));
},
/**
*
@@ -18837,6 +19129,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
mergeKeyword(id: string, target: string, keyword?: Keyword, options?: any): AxiosPromise<Keyword> {
return localVarFp.mergeKeyword(id, target, keyword, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any): AxiosPromise<SupermarketCategory> {
return localVarFp.mergeSupermarketCategory(id, target, supermarketCategory, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this unit.
@@ -18870,6 +19173,16 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
moveKeyword(id: string, parent: string, keyword?: Keyword, options?: any): AxiosPromise<Keyword> {
return localVarFp.moveKeyword(id, parent, keyword, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this supermarket category relation.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
partialUpdateManualOrderBooks(id: string, recipeBook?: RecipeBook, options?: any): AxiosPromise<SupermarketCategoryRelation> {
return localVarFp.partialUpdateManualOrderBooks(id, recipeBook, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id A unique integer value identifying this access token.
@@ -20160,6 +20473,17 @@ export const ApiApiFactory = function (configuration?: Configuration, basePath?:
* @extends {BaseAPI}
*/
export class ApiApi extends BaseAPI {
/**
*
* @param {ShoppingListEntryBulk} [shoppingListEntryBulk]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public bulkShoppingListEntry(shoppingListEntryBulk?: ShoppingListEntryBulk, options?: any) {
return ApiApiFp(this.configuration).bulkShoppingListEntry(shoppingListEntryBulk, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AccessToken} [accessToken]
@@ -20445,7 +20769,6 @@ export class ApiApi extends BaseAPI {
public createRecipeBookEntry(recipeBookEntry?: RecipeBookEntry, options?: any) {
return ApiApiFp(this.configuration).createRecipeBookEntry(recipeBookEntry, options).then((request) => request(this.axios, this.basePath));
}
/**
* function to retrieve a recipe from a given url or source string :param request: standard request with additional post parameters - url: url to use for importing recipe - data: if no url is given recipe is imported from provided source data - (optional) bookmarklet: id of bookmarklet import to use, overrides URL and data attributes :return: JsonResponse containing the parsed json and images
* @param {any} [body]
@@ -21492,12 +21815,13 @@ export class ApiApi extends BaseAPI {
/**
*
* @param {string} [query] Query string matched against supermarket name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public listSupermarkets(options?: any) {
return ApiApiFp(this.configuration).listSupermarkets(options).then((request) => request(this.axios, this.basePath));
public listSupermarkets(query?: string, options?: any) {
return ApiApiFp(this.configuration).listSupermarkets(query, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -21548,12 +21872,13 @@ export class ApiApi extends BaseAPI {
/**
*
* @param {string} [query] Query string matched against user-file name.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public listUserFiles(options?: any) {
return ApiApiFp(this.configuration).listUserFiles(options).then((request) => request(this.axios, this.basePath));
public listUserFiles(query?: string, options?: any) {
return ApiApiFp(this.configuration).listUserFiles(query, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -21636,6 +21961,19 @@ export class ApiApi extends BaseAPI {
return ApiApiFp(this.configuration).mergeKeyword(id, target, keyword, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this supermarket category.
* @param {string} target
* @param {SupermarketCategory} [supermarketCategory]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiApi
*/
public mergeSupermarketCategory(id: string, target: string, supermarketCategory?: SupermarketCategory, options?: any) {
return ApiApiFp(this.configuration).mergeSupermarketCategory(id, target, supermarketCategory, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this unit.
@@ -21674,7 +22012,16 @@ export class ApiApi extends BaseAPI {
public moveKeyword(id: string, parent: string, keyword?: Keyword, options?: any) {
return ApiApiFp(this.configuration).moveKeyword(id, parent, keyword, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this supermarket category relation.
* @param {RecipeBook} [recipeBook]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public partialUpdateManualOrderBooks(id: string, recipeBook?: RecipeBook, options?: any) {
return ApiApiFp(this.configuration).partialUpdateManualOrderBooks(id, recipeBook, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} id A unique integer value identifying this access token.

View File

@@ -131,7 +131,7 @@ export class StandardToasts {
}
let DEBUG = localStorage.getItem("DEBUG") === "True" || always_show_errors
let DEBUG = (localStorage.getItem("DEBUG") === "True" || always_show_errors) && variant !== 'success'
if (DEBUG){
console.log('ERROR ', err, JSON.stringify(err?.response?.data))
console.trace();
@@ -374,6 +374,23 @@ export function energyHeading() {
}
}
export const FormatMixin = {
name: "FormatMixin",
methods: {
/**
* format short date from datetime
* @param datetime any string that can be parsed by Date.parse()
* @return {string}
*/
formatDate: function (datetime) {
return Intl.DateTimeFormat(window.navigator.language, {
dateStyle: "short",
}).format(Date.parse(datetime))
},
},
}
axios.defaults.xsrfCookieName = "csrftoken"
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
@@ -385,7 +402,7 @@ export const ApiMixin = {
}
},
methods: {
// if passing parameters that are not part of the offical schema of the endpoint use parameter: options: {query: {simple: 1}}
// if passing parameters that are not part of the official schema of the endpoint use parameter: options: {query: {simple: 1}}
genericAPI: function (model, action, options) {
let setup = getConfig(model, action)
if (setup?.config?.function) {