Merge shopping_list develop

This commit is contained in:
Tiago Rascazzi
2022-01-04 13:19:34 -05:00
104 changed files with 10372 additions and 5067 deletions

View File

@ -1,195 +0,0 @@
<template>
<div id="app" style="margin-bottom: 4vh" v-if="this_model">
<generic-modal-form v-if="this_model"
:model="this_model"
:action="this_action"
:item1="this_item"
:item2="this_target"
:show="show_modal"
@finish-action="finishAction"/>
<div class="row">
<div class="col-md-2 d-none d-md-block">
</div>
<div class="col-xl-8 col-12">
<div class="container-fluid d-flex flex-column flex-grow-1">
<div class="row">
<div class="col-md-6" style="margin-top: 1vh">
<h3>
<!-- <model-menu/> Replace with a List Menu or a Checklist Menu? -->
<span>{{ this.this_model.name }}</span>
<span><b-button variant="link" @click="startAction({'action':'new'})"><i
class="fas fa-plus-circle fa-2x"></i></b-button></span>
</h3>
</div>
</div>
<div class="row">
<div class="col col-md-12">
this is where shopping list items go
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ApiMixin} from "@/utils/utils";
import {StandardToasts, ToastMixin} from "@/utils/utils";
import GenericModalForm from "@/components/Modals/GenericModalForm";
Vue.use(BootstrapVue)
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: 'ModelListView',
mixins: [ApiMixin, ToastMixin],
components: {GenericModalForm},
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
items: [],
this_model: undefined,
model_menu: undefined,
this_action: undefined,
this_item: {},
show_modal: false,
}
},
mounted() {
// value is passed from lists.py
let model_config = JSON.parse(document.getElementById('model_config').textContent)
this.this_model = this.Models[model_config?.model]
},
methods: {
// this.genericAPI inherited from ApiMixin
startAction: function (e, param) {
let source = e?.source ?? {}
this.this_item = source
// remove recipe from shopping list
// mark on-hand
// mark puchased
// edit shopping category on food
// delete food from shopping list
// add food to shopping list
// add other to shopping list
// edit unit conversion
// edit purchaseable unit
switch (e.action) {
case 'delete':
this.this_action = this.Actions.DELETE
this.show_modal = true
break;
case 'new':
this.this_action = this.Actions.CREATE
this.show_modal = true
break;
case 'edit':
this.this_item = e.source
this.this_action = this.Actions.UPDATE
this.show_modal = true
break;
}
},
finishAction: function (e) {
let update = undefined
switch (e?.action) {
case 'save':
this.saveThis(e.form_data)
break;
}
if (e !== 'cancel') {
switch (this.this_action) {
case this.Actions.DELETE:
this.deleteThis(this.this_item.id)
break;
case this.Actions.CREATE:
this.saveThis(e.form_data)
break;
case this.Actions.UPDATE:
update = e.form_data
update.id = this.this_item.id
this.saveThis(update)
break;
case this.Actions.MERGE:
this.mergeThis(this.this_item, e.form_data.target, false)
break;
case this.Actions.MOVE:
this.moveThis(this.this_item.id, e.form_data.target.id)
break;
}
}
this.clearState()
},
getItems: function (params) {
this.genericAPI(this.this_model, this.Actions.LIST, params).then((results) => {
if (results?.length) {
this.items = results.data
} else {
console.log('no data returned')
}
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
getThis: function (id) {
return this.genericAPI(this.this_model, this.Actions.FETCH, {'id': id})
},
saveThis: function (thisItem) {
if (!thisItem?.id) { // if there is no item id assume it's a new item
this.genericAPI(this.this_model, this.Actions.CREATE, thisItem).then((result) => {
// this.items = result.data - refresh the list here
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
} else {
this.genericAPI(this.this_model, this.Actions.UPDATE, thisItem).then((result) => {
// this.refreshThis(thisItem.id) refresh the list here
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
}).catch((err) => {
console.log(err, err.response)
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
getRecipe: function (item) {
// change to get pop up card? maybe same for unit and food?
},
deleteThis: function (id) {
this.genericAPI(this.this_model, this.Actions.DELETE, {'id': id}).then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
}).catch((err) => {
console.log(err)
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
})
},
clearState: function () {
this.show_modal = false
this.this_action = undefined
this.this_item = undefined
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style>
</style>

View File

@ -1,18 +0,0 @@
import Vue from 'vue'
import App from './ChecklistView'
import i18n from '@/i18n'
Vue.config.productionTip = false
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
let publicPath = localStorage.STATIC_URL + 'vue/'
if (process.env.NODE_ENV === 'development') {
publicPath = 'http://localhost:8080/'
}
export default __webpack_public_path__ = publicPath // eslint-disable-line
new Vue({
i18n,
render: h => h(App),
}).$mount('#app')

View File

@ -1,158 +1,163 @@
<template>
<div id="app" class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1 offset">
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
<div class="row">
<div class="col col-md-12">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 mt-3 mb-3">
<b-input-group>
<b-input class="form-control form-control-lg form-control-borderless form-control-search"
v-model="search"
v-bind:placeholder="$t('Search')"></b-input>
<b-input-group-append>
<b-button variant="primary"
v-b-tooltip.hover :title="$t('Create')"
@click="createNew">
<i class="fas fa-plus"></i>
</b-button>
</b-input-group-append>
</b-input-group>
</div>
</div>
</div>
</div>
</div>
<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="2" style="height:inherit;">
<h3>{{ book.icon }}</h3>
</b-col>
<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 id="app" class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1 offset">
<div class="col-12 col-xl-8 col-lg-10 offset-xl-2 offset-lg-1">
<div class="row">
<div class="col col-md-12">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 mt-3 mb-3">
<b-input-group>
<b-input
class="form-control form-control-lg form-control-borderless form-control-search"
v-model="search"
v-bind:placeholder="$t('Search')"
></b-input>
<b-input-group-append>
<b-button variant="primary" v-b-tooltip.hover :title="$t('Create')" @click="createNew">
<i class="fas fa-plus"></i>
</b-button>
</b-input-group-append>
</b-input-group>
</div>
</div>
</b-card-text>
</b-card-body>
</b-col>
</b-row>
</b-card>
</div>
</div>
</div>
</div>
<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="2" style="height: inherit">
<h3>{{ book.icon }}</h3>
</b-col>
<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>
</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"></cookbook-slider>
</transition>
<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"
></cookbook-slider>
</transition>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {ApiApiFactory} from "@/utils/openapi/api";
import CookbookSlider from "@/components/CookbookSlider";
import LoadingSpinner from "@/components/LoadingSpinner";
import {StandardToasts} from "@/utils/utils";
import "bootstrap-vue/dist/bootstrap-vue.css"
import { ApiApiFactory } from "@/utils/openapi/api"
import CookbookSlider from "@/components/CookbookSlider"
import LoadingSpinner from "@/components/LoadingSpinner"
import { StandardToasts } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
name: 'CookbookView',
mixins: [],
components: {LoadingSpinner, CookbookSlider},
data() {
return {
cookbooks: [],
book_background: window.IMAGE_BOOK,
recipes: [],
current_book: undefined,
loading: false,
search: ''
}
},
computed: {
filteredBooks: function () {
return this.cookbooks.filter(book => {
return book.name.toLowerCase().includes(this.search.toLowerCase())
})
}
},
mounted() {
this.refreshData()
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
refreshData: function () {
let apiClient = new ApiApiFactory()
apiClient.listRecipeBooks().then(result => {
this.cookbooks = result.data
})
name: "CookbookView",
mixins: [],
components: { LoadingSpinner, CookbookSlider },
data() {
return {
cookbooks: [],
book_background: window.IMAGE_BOOK,
recipes: [],
current_book: undefined,
loading: false,
search: "",
}
},
openBook: function (book) {
if (book === this.current_book) {
this.current_book = undefined
this.recipes = []
return
}
this.loading = true
let apiClient = new ApiApiFactory()
this.current_book = book
apiClient.listRecipeBookEntrys({query: {book: book}}).then(result => {
this.recipes = result.data
this.loading = false
})
computed: {
filteredBooks: function () {
return this.cookbooks.filter((book) => {
return book.name.toLowerCase().includes(this.search.toLowerCase())
})
},
},
createNew: function () {
let apiClient = new ApiApiFactory()
apiClient.createRecipeBook({name: this.$t('New_Cookbook'), description: '', icon: '', shared: []}).then(result => {
let new_book = result.data
mounted() {
this.refreshData()
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch(error => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
}
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener('mouseenter', () => {
el.classList.add("shadow")
});
el.addEventListener('mouseleave', () => {
el.classList.remove("shadow")
});
}
}
}
}
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
refreshData: function () {
let apiClient = new ApiApiFactory()
apiClient.listRecipeBooks().then((result) => {
this.cookbooks = result.data
})
},
openBook: function (book) {
if (book === this.current_book) {
this.current_book = undefined
this.recipes = []
return
}
this.loading = true
let apiClient = new ApiApiFactory()
this.current_book = book
apiClient.listRecipeBookEntrys({ query: { book: book } }).then((result) => {
this.recipes = result.data
this.loading = false
})
},
createNew: function () {
let apiClient = new ApiApiFactory()
apiClient
.createRecipeBook({ name: this.$t("New_Cookbook"), description: "", icon: "", shared: [] })
.then((result) => {
let new_book = result.data
this.refreshData()
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((error) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener("mouseenter", () => {
el.classList.add("shadow")
})
el.addEventListener("mouseleave", () => {
el.classList.remove("shadow")
})
},
},
},
}
</script>
<style>
.slide-fade-enter-active {
transition: all .6s ease;
transition: all 0.6s ease;
}
.slide-fade-enter, .slide-fade-leave-to
/* .slide-fade-leave-active below version 2.1.8 */
{
transform: translateX(10px);
opacity: 0;
/* .slide-fade-leave-active below version 2.1.8 */ {
transform: translateX(10px);
opacity: 0;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -19,20 +19,15 @@
<!-- <span><b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button></span> -->
<model-menu />
<span>{{ this.this_model.name }}</span>
<span v-if="this_model.name !== 'Step'"
><b-button variant="link" @click="startAction({ action: 'new' })"><i class="fas fa-plus-circle fa-2x"></i></b-button></span
<span v-if="apiName !== 'Step'">
<b-button variant="link" @click="startAction({ action: 'new' })">
<i class="fas fa-plus-circle fa-2x"></i>
</b-button> </span
><!-- TODO add proper field to model config to determine if create should be available or not -->
</h3>
</div>
<div class="col-md-3" style="position: relative; margin-top: 1vh">
<b-form-checkbox
v-model="show_split"
name="check-button"
v-if="paginated"
class="shadow-none"
style="position: relative; top: 50%; transform: translateY(-50%)"
switch
>
<b-form-checkbox v-model="show_split" name="check-button" v-if="paginated" class="shadow-none" style="position: relative; top: 50%; transform: translateY(-50%)" switch>
{{ $t("show_split_screen") }}
</b-form-checkbox>
</div>
@ -42,46 +37,19 @@
<div class="col" :class="{ 'col-md-6': show_split }">
<!-- model isn't paginated and loads in one API call -->
<div v-if="!paginated">
<generic-horizontal-card
v-for="i in items_left"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"
/>
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
</div>
<!-- model is paginated and needs managed -->
<generic-infinite-cards v-if="paginated" :card_counts="left_counts" :scroll="show_split" @search="getItems($event, 'left')" @reset="resetList('left')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_left"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'left')"
@finish-action="finishAction"
/>
<generic-horizontal-card v-for="i in items_left" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'left')" @finish-action="finishAction" />
</template>
</generic-infinite-cards>
</div>
<div class="col col-md-6" v-if="show_split">
<generic-infinite-cards
v-if="this_model"
:card_counts="right_counts"
:scroll="show_split"
@search="getItems($event, 'right')"
@reset="resetList('right')"
>
<generic-infinite-cards v-if="this_model" :card_counts="right_counts" :scroll="show_split" @search="getItems($event, 'right')" @reset="resetList('right')">
<template v-slot:cards>
<generic-horizontal-card
v-for="i in items_right"
v-bind:key="i.id"
:item="i"
:model="this_model"
@item-action="startAction($event, 'right')"
@finish-action="finishAction"
/>
<generic-horizontal-card v-for="i in items_right" v-bind:key="i.id" :item="i" :model="this_model" @item-action="startAction($event, 'right')" @finish-action="finishAction" />
</template>
</generic-infinite-cards>
</div>
@ -104,7 +72,7 @@ import { StandardToasts, ToastMixin } from "@/utils/utils"
import GenericInfiniteCards from "@/components/GenericInfiniteCards"
import GenericHorizontalCard from "@/components/GenericHorizontalCard"
import GenericModalForm from "@/components/Modals/GenericModalForm"
import ModelMenu from "@/components/ModelMenu"
import ModelMenu from "@/components/ContextMenu/ModelMenu"
import { ApiApiFactory } from "@/utils/openapi/api"
//import StorageQuota from "@/components/StorageQuota";
@ -146,6 +114,9 @@ export default {
// TODO this is not necessarily bad but maybe there are better options to do this
return () => import(/* webpackChunkName: "header-component" */ `@/components/${this.header_component_name}`)
},
apiName() {
return this.this_model?.apiName
},
},
mounted() {
// value is passed from lists.py
@ -236,6 +207,7 @@ export default {
}
},
finishAction: function (e) {
let update = undefined
switch (e?.action) {
case "save":
this.saveThis(e.form_data)
@ -244,7 +216,6 @@ export default {
if (e !== "cancel") {
switch (this.this_action) {
case this.Actions.DELETE:
console.log("delete")
this.deleteThis(this.this_item.id)
break
case this.Actions.CREATE:
@ -263,7 +234,7 @@ export default {
}
this.clearState()
},
getItems: function (params, col) {
getItems: function (params = {}, col) {
let column = col || "left"
params.options = { query: { extended: 1 } } // returns extended values in API response
this.genericAPI(this.this_model, this.Actions.LIST, params)
@ -315,6 +286,16 @@ export default {
// this creates a deep copy to make sure that columns stay independent
this.items_right = [{ ...item }].concat(this.destroyCard(item?.id, this.items_right))
},
// this currently assumes shopping is only applicable on FOOD model
addShopping: function (food) {
let api = new ApiApiFactory()
food.shopping = true
api.createShoppingListEntry({ food: food, amount: 1 }).then(() => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
this.refreshCard(food, this.items_left)
this.refreshCard({ ...food }, this.items_right)
})
},
updateThis: function (item) {
this.refreshThis(item.id)
},
@ -334,8 +315,7 @@ export default {
this.genericAPI(this.this_model, this.Actions.MOVE, { source: source_id, target: target_id })
.then((result) => {
this.moveUpdateItem(source_id, target_id)
// TODO make standard toast
this.makeToast(this.$t("Success"), "Succesfully moved resource", "success")
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MOVE)
})
.catch((err) => {
console.log(err)
@ -374,8 +354,7 @@ export default {
})
.then((result) => {
this.mergeUpdateItem(source_id, target_id)
// TODO make standard toast
this.makeToast(this.$t("Success"), "Succesfully merged resource", "success")
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_MERGE)
})
.catch((err) => {
//TODO error checking not working with OpenAPI methods
@ -429,7 +408,7 @@ export default {
})
.catch((err) => {
console.log(err)
this.makeToast(this.$t("Error"), err.bodyText, "danger")
StandardToasts.makeStandardToast(StandardToasts.FAIL_FETCH)
})
},
getRecipes: function (col, item) {

View File

@ -630,7 +630,6 @@ export default {
apiFactory.updateRecipe(this.recipe_id, this.recipe,
{}).then((response) => {
console.log(response)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
this.recipe_changed = false
if (view_after) {

View File

@ -238,7 +238,7 @@ Vue.use(VueCookies)
import { ApiMixin, ResolveUrlMixin } from "@/utils/utils"
import LoadingSpinner from "@/components/LoadingSpinner" // is this deprecated?
import LoadingSpinner from "@/components/LoadingSpinner" // TODO: is this deprecated?
import RecipeCard from "@/components/RecipeCard"
import GenericMultiselect from "@/components/GenericMultiselect"

View File

@ -1,274 +1,260 @@
<template>
<div id="app">
<template v-if="loading">
<loading-spinner></loading-spinner>
</template>
<div id="app">
<template v-if="loading">
<loading-spinner></loading-spinner>
</template>
<div v-if="!loading">
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3>
</div>
</div>
<div class="row text-center">
<div class="col col-md-12">
<recipe-rating :recipe="recipe"></recipe-rating>
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
</div>
</div>
<div class="my-auto">
<div class="col-12" style="text-align: center">
<i>{{ recipe.description }}</i>
</div>
</div>
<div style="text-align: center">
<keywords-component :recipe="recipe"></keywords-component>
</div>
<hr/>
<div class="row">
<div class="col col-md-3">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-user-clock fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"><b>{{ $t('Preparation') }}</b></span><br/>
{{ recipe.working_time }} {{ $t('min') }}
</div>
</div>
</div>
<div class="col col-md-3">
<div class="row d-flex">
<div class="my-auto" style="padding-right: 4px">
<i class="far fa-clock fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"><b>{{ $t('Waiting') }}</b></span><br/>
{{ recipe.waiting_time }} {{ $t('min') }}
</div>
</div>
</div>
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<input
style="text-align: right; border-width:0px;border:none; padding:0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
value="1" maxlength="3" min="0"
type="number" class="form-control form-control-lg" v-model.number="servings"/>
</div>
<div class="my-auto ">
<span class="text-primary"><b><template v-if="recipe.servings_text === ''">{{ $t('Servings') }}</template><template
v-else>{{ recipe.servings_text }}</template></b></span>
</div>
</div>
</div>
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
<div class="card border-primary">
<div class="card-body">
<div class="row">
<div class="col col-md-8">
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h4>
<div v-if="!loading">
<div class="row">
<div class="col-12" style="text-align: center">
<h3>{{ recipe.name }}</h3>
</div>
</div>
<br/>
<template v-for="s in recipe.steps" v-bind:key="s.id">
<div class="row" >
<div class="col-md-12">
<template v-if="s.show_as_header && s.name !== '' && s.ingredients.length > 0">
<b v-bind:key="s.id">{{s.name}}</b>
</template>
<table class="table table-sm">
<template v-for="i in s.ingredients" :key="i.id">
<ingredient-component :ingredient="i" :ingredient_factor="ingredient_factor"
@checked-state-changed="updateIngredientCheckedState"></ingredient-component>
</template>
</table>
</div>
</div>
<div class="row text-center">
<div class="col col-md-12">
<recipe-rating :recipe="recipe"></recipe-rating>
<last-cooked :recipe="recipe" class="mt-2"></last-cooked>
</div>
</template>
</div>
</div>
</div>
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
<div class="row">
<div class="col-12">
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh;"
:alt="$t( 'Recipe_Image')" v-if="recipe.image !== null">
<div class="my-auto">
<div class="col-12" style="text-align: center">
<i>{{ recipe.description }}</i>
</div>
</div>
</div>
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
<div class="col-12">
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
<div style="text-align: center">
<keywords-component :recipe="recipe"></keywords-component>
</div>
<hr />
<div class="row">
<div class="col col-md-3">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-user-clock fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"
><b>{{ $t("Preparation") }}</b></span
><br />
{{ recipe.working_time }} {{ $t("min") }}
</div>
</div>
</div>
<div class="col col-md-3">
<div class="row d-flex">
<div class="my-auto" style="padding-right: 4px">
<i class="far fa-clock fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<span class="text-primary"
><b>{{ $t("Waiting") }}</b></span
><br />
{{ recipe.waiting_time }} {{ $t("min") }}
</div>
</div>
</div>
<div class="col col-md-4 col-10 mt-2 mt-md-0 mt-lg-0 mt-xl-0">
<div class="row d-flex" style="padding-left: 16px">
<div class="my-auto" style="padding-right: 4px">
<i class="fas fa-pizza-slice fa-2x text-primary"></i>
</div>
<div class="my-auto" style="padding-right: 4px">
<input
style="text-align: right; border-width: 0px; border: none; padding: 0px; padding-left: 0.5vw; padding-right: 8px; max-width: 80px"
value="1"
maxlength="3"
min="0"
type="number"
class="form-control form-control-lg"
v-model.number="servings"
/>
</div>
<div class="my-auto">
<span class="text-primary"
><b
><template v-if="recipe.servings_text === ''">{{ $t("Servings") }}</template
><template v-else>{{ recipe.servings_text }}</template></b
></span
>
</div>
</div>
</div>
<div class="col col-md-2 col-2 my-auto" style="text-align: right; padding-right: 1vw">
<recipe-context-menu v-bind:recipe="recipe" :servings="servings"></recipe-context-menu>
</div>
</div>
<hr />
<div class="row">
<div class="col-md-6 order-md-1 col-sm-12 order-sm-2 col-12 order-2" v-if="recipe && ingredient_count > 0">
<ingredients-card
:recipe="recipe.id"
:steps="recipe.steps"
:ingredient_factor="ingredient_factor"
:servings="servings"
:header="true"
@checked-state-changed="updateIngredientCheckedState"
/>
</div>
<div class="col-12 order-1 col-sm-12 order-sm-1 col-md-6 order-md-2">
<div class="row">
<div class="col-12">
<img class="img img-fluid rounded" :src="recipe.image" style="max-height: 30vh" :alt="$t('Recipe_Image')" v-if="recipe.image !== null" />
</div>
</div>
<div class="row" style="margin-top: 2vh; margin-bottom: 2vh">
<div class="col-12">
<Nutrition-component :recipe="recipe" :ingredient_factor="ingredient_factor"></Nutrition-component>
</div>
</div>
</div>
</div>
<template v-if="!recipe.internal">
<div v-if="recipe.file_path.includes('.pdf')">
<PdfViewer :recipe="recipe"></PdfViewer>
</div>
<div v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
<ImageViewer :recipe="recipe"></ImageViewer>
</div>
</template>
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
<step-component
:recipe="recipe"
:step="s"
:ingredient_factor="ingredient_factor"
:index="index"
:start_time="start_time"
@update-start-time="updateStartTime"
@checked-state-changed="updateIngredientCheckedState"
></step-component>
</div>
</div>
</div>
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
</div>
<template v-if="!recipe.internal">
<div v-if="recipe.file_path.includes('.pdf')">
<PdfViewer :recipe="recipe"></PdfViewer>
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
<div class="col col-md-12">
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t("Report Abuse") }}</a>
</div>
</div>
<div
v-if="recipe.file_path.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
<ImageViewer :recipe="recipe"></ImageViewer>
</div>
</template>
<div v-for="(s, index) in recipe.steps" v-bind:key="s.id" style="margin-top: 1vh">
<step-component :recipe="recipe" :step="s" :ingredient_factor="ingredient_factor" :index="index" :start_time="start_time"
@update-start-time="updateStartTime" @checked-state-changed="updateIngredientCheckedState"></step-component>
</div>
</div>
<add-recipe-to-book :recipe="recipe"></add-recipe-to-book>
<div class="row text-center d-print-none" style="margin-top: 3vh; margin-bottom: 3vh" v-if="share_uid !== 'None'">
<div class="col col-md-12">
<a :href="resolveDjangoUrl('view_report_share_abuse', share_uid)">{{ $t('Report Abuse') }}</a>
</div>
</div>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import {apiLoadRecipe} from "@/utils/api";
import { apiLoadRecipe } from "@/utils/api"
import Step from "@/components/StepComponent";
import RecipeContextMenu from "@/components/RecipeContextMenu";
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
import Ingredient from "@/components/IngredientComponent";
import RecipeContextMenu from "@/components/RecipeContextMenu"
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
import PdfViewer from "@/components/PdfViewer";
import ImageViewer from "@/components/ImageViewer";
import Nutrition from "@/components/NutritionComponent";
import PdfViewer from "@/components/PdfViewer"
import ImageViewer from "@/components/ImageViewer"
import moment from 'moment'
import Keywords from "@/components/KeywordsComponent";
import LoadingSpinner from "@/components/LoadingSpinner";
import AddRecipeToBook from "@/components/AddRecipeToBook";
import RecipeRating from "@/components/RecipeRating";
import LastCooked from "@/components/LastCooked";
import IngredientComponent from "@/components/IngredientComponent";
import StepComponent from "@/components/StepComponent";
import KeywordsComponent from "@/components/KeywordsComponent";
import NutritionComponent from "@/components/NutritionComponent";
import moment from "moment"
import LoadingSpinner from "@/components/LoadingSpinner"
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
import RecipeRating from "@/components/RecipeRating"
import LastCooked from "@/components/LastCooked"
import IngredientsCard from "@/components/IngredientsCard"
import StepComponent from "@/components/StepComponent"
import KeywordsComponent from "@/components/KeywordsComponent"
import NutritionComponent from "@/components/NutritionComponent"
Vue.prototype.moment = moment
Vue.use(BootstrapVue)
export default {
name: 'RecipeView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
components: {
LastCooked,
RecipeRating,
PdfViewer,
ImageViewer,
IngredientComponent,
StepComponent,
RecipeContextMenu,
NutritionComponent,
KeywordsComponent,
LoadingSpinner,
AddRecipeToBook,
},
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings
name: "RecipeView",
mixins: [ResolveUrlMixin, ToastMixin],
components: {
LastCooked,
RecipeRating,
PdfViewer,
ImageViewer,
IngredientsCard,
StepComponent,
RecipeContextMenu,
NutritionComponent,
KeywordsComponent,
LoadingSpinner,
AddRecipeToBook,
},
},
data() {
return {
loading: true,
recipe: undefined,
ingredient_count: 0,
servings: 1,
start_time: "",
share_uid: window.SHARE_UID
}
},
mounted() {
this.loadRecipe(window.RECIPE_ID)
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
loadRecipe: function (recipe_id) {
apiLoadRecipe(recipe_id).then(recipe => {
if (window.USER_SERVINGS !== 0) {
recipe.servings = window.USER_SERVINGS
}
this.servings = recipe.servings
let total_time = 0
for (let step of recipe.steps) {
this.ingredient_count += step.ingredients.length
for (let ingredient of step.ingredients) {
this.$set(ingredient, 'checked', false)
}
step.time_offset = total_time
total_time += step.time
}
// set start time only if there are any steps with timers (otherwise no timers are rendered)
if (total_time > 0) {
this.start_time = moment().format('yyyy-MM-DDTHH:mm')
}
this.recipe = recipe
this.loading = false
})
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings
},
},
updateStartTime: function (e) {
this.start_time = e
},
updateIngredientCheckedState: function (e) {
for (let step of this.recipe.steps) {
for (let ingredient of step.ingredients) {
if (ingredient.id === e.id) {
this.$set(ingredient, 'checked', !ingredient.checked)
}
data() {
return {
loading: true,
recipe: undefined,
ingredient_count: 0,
servings: 1,
start_time: "",
share_uid: window.SHARE_UID,
}
}
},
}
mounted() {
this.loadRecipe(window.RECIPE_ID)
this.$i18n.locale = window.CUSTOM_LOCALE
},
methods: {
loadRecipe: function (recipe_id) {
apiLoadRecipe(recipe_id).then((recipe) => {
if (window.USER_SERVINGS !== 0) {
recipe.servings = window.USER_SERVINGS
}
this.servings = recipe.servings
let total_time = 0
for (let step of recipe.steps) {
this.ingredient_count += step.ingredients.length
for (let ingredient of step.ingredients) {
this.$set(ingredient, "checked", false)
}
step.time_offset = total_time
total_time += step.time
}
// set start time only if there are any steps with timers (otherwise no timers are rendered)
if (total_time > 0) {
this.start_time = moment().format("yyyy-MM-DDTHH:mm")
}
this.recipe = recipe
this.loading = false
})
},
updateStartTime: function (e) {
this.start_time = e
},
updateIngredientCheckedState: function (e) {
for (let step of this.recipe.steps) {
for (let ingredient of step.ingredients) {
if (ingredient.id === e.id) {
this.$set(ingredient, "checked", !ingredient.checked)
}
}
}
},
},
}
</script>
<style>
@ -276,4 +262,4 @@ export default {
break-inside: avoid;
}
</style>
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,17 @@
import i18n from "@/i18n"
import Vue from "vue"
import App from "./ShoppingListView"
Vue.config.productionTip = false
// TODO move this and other default stuff to centralized JS file (verify nothing breaks)
let publicPath = localStorage.STATIC_URL + "vue/"
if (process.env.NODE_ENV === "development") {
publicPath = "http://localhost:8080/"
}
export default __webpack_public_path__ = publicPath // eslint-disable-line
new Vue({
i18n,
render: (h) => h(App),
}).$mount("#app")

View File

@ -1,201 +1,178 @@
<template>
<!-- TODO: Deprecate -->
<div id="app">
<div class="row">
<div class="col col-md-12">
<h2>{{ $t("Supermarket") }}</h2>
<div id="app">
<multiselect v-model="selected_supermarket" track-by="id" label="name" :options="supermarkets" @input="selectedSupermarketChanged"> </multiselect>
<div class="row">
<b-button class="btn btn-primary btn-block" style="margin-top: 1vh" v-b-modal.modal-supermarket>
{{ $t("Edit") }}
</b-button>
<b-button class="btn btn-success btn-block" @click="selected_supermarket = { new: true, name: '' }" v-b-modal.modal-supermarket>{{ $t("New") }} </b-button>
</div>
</div>
<div class="col col-md-12">
<h2>{{ $t('Supermarket') }}</h2>
<hr />
<multiselect v-model="selected_supermarket" track-by="id" label="name"
:options="supermarkets" @input="selectedSupermarketChanged">
</multiselect>
<div class="row">
<div class="col col-md-6">
<h4>
{{ $t("Categories") }}
<button class="btn btn-success btn-sm" @click="selected_category = { new: true, name: '' }" v-b-modal.modal-category>{{ $t("New") }}</button>
</h4>
<b-button class="btn btn-primary btn-block" style="margin-top: 1vh" v-b-modal.modal-supermarket>
{{ $t('Edit') }}
</b-button>
<b-button class="btn btn-success btn-block" @click="selected_supermarket = {new:true, name:''}"
v-b-modal.modal-supermarket>{{ $t('New') }}
</b-button>
</div>
<draggable :list="selectable_categories" group="supermarket_categories" :empty-insert-threshold="10">
<div v-for="c in selectable_categories" :key="c.id">
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
</div>
</draggable>
</div>
<div class="col col-md-6">
<h4>{{ $t("Selected") }} {{ $t("Categories") }}</h4>
<draggable :list="supermarket_categories" group="supermarket_categories" :empty-insert-threshold="10" @change="selectedCategoriesChanged">
<div v-for="c in supermarket_categories" :key="c.id">
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
</div>
</draggable>
</div>
</div>
<!-- EDIT MODALS -->
<b-modal id="modal-supermarket" v-bind:title="$t('Supermarket')" @ok="supermarketModalOk()">
<label v-if="selected_supermarket !== undefined">
{{ $t("Name") }}
<b-input v-model="selected_supermarket.name"></b-input>
</label>
</b-modal>
<b-modal id="modal-category" v-bind:title="$t('Category')" @ok="categoryModalOk()">
<label v-if="selected_category !== undefined">
{{ $t("Name") }}
<b-input v-model="selected_category.name"></b-input>
</label>
</b-modal>
</div>
<hr>
<div class="row">
<div class="col col-md-6">
<h4>{{ $t('Categories') }}
<button class="btn btn-success btn-sm" @click="selected_category = {new:true, name:''}"
v-b-modal.modal-category>{{ $t('New') }}
</button>
</h4>
<draggable :list="selectable_categories" group="supermarket_categories"
:empty-insert-threshold="10">
<div v-for="c in selectable_categories" :key="c.id">
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
</div>
</draggable>
</div>
<div class="col col-md-6">
<h4>{{ $t('Selected') }} {{ $t('Categories') }}</h4>
<draggable :list="supermarket_categories" group="supermarket_categories"
:empty-insert-threshold="10" @change="selectedCategoriesChanged">
<div v-for="c in supermarket_categories" :key="c.id">
<button class="btn btn-block btn-sm btn-primary" style="margin-top: 0.5vh">{{ c.name }}</button>
</div>
</draggable>
</div>
</div>
<!-- EDIT MODALS -->
<b-modal id="modal-supermarket" v-bind:title="$t('Supermarket')" @ok="supermarketModalOk()">
<label v-if="selected_supermarket !== undefined">
{{ $t('Name') }}
<b-input v-model="selected_supermarket.name"></b-input>
</label>
</b-modal>
<b-modal id="modal-category" v-bind:title="$t('Category')" @ok="categoryModalOk()">
<label v-if="selected_category !== undefined">
{{ $t('Name') }}
<b-input v-model="selected_category.name"></b-input>
</label>
</b-modal>
</div>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import 'bootstrap-vue/dist/bootstrap-vue.css'
import "bootstrap-vue/dist/bootstrap-vue.css"
import {ResolveUrlMixin, ToastMixin} from "@/utils/utils";
import { ResolveUrlMixin, ToastMixin } from "@/utils/utils"
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import { ApiApiFactory } from "@/utils/openapi/api.ts"
Vue.use(BootstrapVue)
import draggable from 'vuedraggable'
import draggable from "vuedraggable"
import axios from 'axios'
import Multiselect from "vue-multiselect";
import axios from "axios"
import Multiselect from "vue-multiselect"
axios.defaults.xsrfHeaderName = 'X-CSRFToken'
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFToken"
axios.defaults.xsrfCookieName = "csrftoken"
export default {
name: 'SupermarketView',
mixins: [
ResolveUrlMixin,
ToastMixin,
],
components: {
Multiselect,
draggable
},
data() {
return {
supermarkets: [],
categories: [],
selected_supermarket: {},
selected_category: {},
selectable_categories: [],
supermarket_categories: [],
}
},
mounted() {
this.$i18n.locale = window.CUSTOM_LOCALE
this.loadInitial()
},
methods: {
loadInitial: function () {
let apiClient = new ApiApiFactory()
apiClient.listSupermarkets().then(results => {
this.supermarkets = results.data
})
apiClient.listSupermarketCategorys().then(results => {
this.categories = results.data
this.selectable_categories = this.categories
})
name: "SupermarketView",
mixins: [ResolveUrlMixin, ToastMixin],
components: {
Multiselect,
draggable,
},
selectedCategoriesChanged: function (data) {
let apiClient = new ApiApiFactory()
data() {
return {
supermarkets: [],
categories: [],
if ('removed' in data) {
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === data.removed.element.id)[0]
apiClient.destroySupermarketCategoryRelation(relation.id)
}
selected_supermarket: {},
selected_category: {},
if ('added' in data) {
apiClient.createSupermarketCategoryRelation({
category: data.added.element,
supermarket: this.selected_supermarket.id, order: 0
}).then(results => {
this.selected_supermarket.category_to_supermarket.push(results.data)
})
}
if ('moved' in data || 'added' in data) {
this.supermarket_categories.forEach( (element,index) =>{
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0]
console.log(relation)
apiClient.partialUpdateSupermarketCategoryRelation(relation.id, {order: index})
})
}
selectable_categories: [],
supermarket_categories: [],
}
},
selectedSupermarketChanged: function (supermarket, id) {
this.supermarket_categories = []
this.selectable_categories = this.categories
for (let i of supermarket.category_to_supermarket) {
this.supermarket_categories.push(i.category)
this.selectable_categories = this.selectable_categories.filter(function (el) {
return el.id !== i.category.id
});
}
mounted() {
this.$i18n.locale = window.CUSTOM_LOCALE
this.loadInitial()
},
supermarketModalOk: function () {
let apiClient = new ApiApiFactory()
if (this.selected_supermarket.new) {
apiClient.createSupermarket({name: this.selected_supermarket.name}).then(results => {
this.selected_supermarket = undefined
this.loadInitial()
})
} else {
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, {name: this.selected_supermarket.name})
methods: {
loadInitial: function() {
let apiClient = new ApiApiFactory()
apiClient.listSupermarkets().then((results) => {
this.supermarkets = results.data
})
apiClient.listSupermarketCategorys().then((results) => {
this.categories = results.data
this.selectable_categories = this.categories
})
},
selectedCategoriesChanged: function(data) {
let apiClient = new ApiApiFactory()
}
if ("removed" in data) {
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === data.removed.element.id)[0]
apiClient.destroySupermarketCategoryRelation(relation.id)
}
if ("added" in data) {
apiClient
.createSupermarketCategoryRelation({
category: data.added.element,
supermarket: this.selected_supermarket.id,
order: 0,
})
.then((results) => {
this.selected_supermarket.category_to_supermarket.push(results.data)
})
}
if ("moved" in data || "added" in data) {
this.supermarket_categories.forEach((element, index) => {
let relation = this.selected_supermarket.category_to_supermarket.filter((el) => el.category.id === element.id)[0]
console.log(relation)
apiClient.partialUpdateSupermarketCategoryRelation(relation.id, { order: index })
})
}
},
selectedSupermarketChanged: function(supermarket, id) {
this.supermarket_categories = []
this.selectable_categories = this.categories
for (let i of supermarket.category_to_supermarket) {
this.supermarket_categories.push(i.category)
this.selectable_categories = this.selectable_categories.filter(function(el) {
return el.id !== i.category.id
})
}
},
supermarketModalOk: function() {
let apiClient = new ApiApiFactory()
if (this.selected_supermarket.new) {
apiClient.createSupermarket({ name: this.selected_supermarket.name }).then((results) => {
this.selected_supermarket = undefined
this.loadInitial()
})
} else {
apiClient.partialUpdateSupermarket(this.selected_supermarket.id, { name: this.selected_supermarket.name })
}
},
categoryModalOk: function() {
let apiClient = new ApiApiFactory()
if (this.selected_category.new) {
apiClient.createSupermarketCategory({ name: this.selected_category.name }).then((results) => {
this.selected_category = {}
this.loadInitial()
})
} else {
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, { name: this.selected_category.name })
}
},
},
categoryModalOk: function () {
let apiClient = new ApiApiFactory()
if (this.selected_category.new) {
apiClient.createSupermarketCategory({name: this.selected_category.name}).then(results => {
this.selected_category = {}
this.loadInitial()
})
} else {
apiClient.partialUpdateSupermarketCategory(this.selected_category.id, {name: this.selected_category.name})
}
}
}
}
</script>
<style>
</style>
<style></style>

View File

@ -1,40 +1,44 @@
<template>
<span>
<linked-recipe v-if="linkedRecipe"
:item="item"/>
<icon-badge v-if="Icon"
:item="item"/>
<linked-recipe v-if="linkedRecipe" :item="item" />
<icon-badge v-if="Icon" :item="item" />
<on-hand-badge v-if="OnHand" :item="item" />
<shopping-badge v-if="Shopping" :item="item" />
</span>
</template>
<script>
import LinkedRecipe from "@/components/Badges/LinkedRecipe";
import IconBadge from "@/components/Badges/Icon";
import LinkedRecipe from "@/components/Badges/LinkedRecipe"
import IconBadge from "@/components/Badges/Icon"
import OnHandBadge from "@/components/Badges/OnHand"
import ShoppingBadge from "@/components/Badges/Shopping"
export default {
name: 'CardBadges',
components: {LinkedRecipe, IconBadge},
props: {
item: {type: Object},
model: {type: Object}
},
data() {
return {
}
},
mounted() {
},
computed: {
linkedRecipe: function () {
return this.model?.badges?.linked_recipe ?? false
name: "CardBadges",
components: { LinkedRecipe, IconBadge, OnHandBadge, ShoppingBadge },
props: {
item: { type: Object },
model: { type: Object },
},
Icon: function () {
return this.model?.badges?.icon ?? false
}
},
watch: {
},
methods: {
}
data() {
return {}
},
mounted() {},
computed: {
linkedRecipe: function () {
return this.model?.badges?.linked_recipe ?? false
},
Icon: function () {
return this.model?.badges?.icon ?? false
},
OnHand: function () {
return this.model?.badges?.food_onhand ?? false
},
Shopping: function () {
return this.model?.badges?.shopping ?? false
},
},
watch: {},
methods: {},
}
</script>
</script>

View File

@ -1,6 +1,6 @@
<template>
<span>
<b-button v-if="item.icon" class=" btn p-0 border-0" variant="link">
<b-button v-if="item.icon" class=" btn px-1 py-0 border-0 text-decoration-none" variant="link">
{{item.icon}}
</b-button>
</span>

View File

@ -1,7 +1,7 @@
<template>
<span>
<b-button v-if="item.recipe" v-b-tooltip.hover :title="item.recipe.name"
class=" btn fas fa-book-open p-0 border-0" variant="link" :href="item.recipe.url"/>
class=" btn text-decoration-none fas fa-book-open px-1 py-0 border-0" variant="link" :href="item.recipe.url"/>
</span>
</template>

View File

@ -0,0 +1,45 @@
<template>
<span>
<b-button
class="btn text-decoration-none fas px-1 py-0 border-0"
variant="link"
v-b-popover.hover.html
:title="[onhand ? $t('FoodOnHand', { food: item.name }) : $t('FoodNotOnHand', { food: item.name })]"
:class="[onhand ? 'text-success fa-clipboard-check' : 'text-muted fa-clipboard']"
@click="toggleOnHand"
/>
</span>
</template>
<script>
import { ApiMixin } from "@/utils/utils"
export default {
name: "OnHandBadge",
props: {
item: { type: Object },
},
mixins: [ApiMixin],
data() {
return {
onhand: false,
}
},
mounted() {
this.onhand = this.item.food_onhand
},
watch: {
"item.food_onhand": function (newVal, oldVal) {
this.onhand = newVal
},
},
methods: {
toggleOnHand() {
let params = { id: this.item.id, food_onhand: !this.onhand }
this.genericAPI(this.Models.FOOD, this.Actions.UPDATE, params).then(() => {
this.onhand = !this.onhand
})
},
},
}
</script>

View File

@ -0,0 +1,88 @@
<template>
<span>
<b-button class="btn text-decoration-none px-1 border-0" variant="link" :id="`shopping${item.id}`" @click="addShopping()">
<i
class="fas"
v-b-popover.hover.html
:title="[shopping ? $t('RemoveFoodFromShopping', { food: item.name }) : $t('AddFoodToShopping', { food: item.name })]"
:class="[shopping ? 'text-success fa-shopping-cart' : 'text-muted fa-cart-plus']"
/>
</b-button>
<b-popover v-if="shopping" :target="`${ShowConfirmation}`" :ref="'shopping' + item.id" triggers="focus" placement="top">
<template #title>{{ DeleteConfirmation }}</template>
<b-row align-h="end">
<b-col cols="auto">
<b-button class="btn btn-sm btn-info shadow-none px-1 border-0" @click="cancelDelete()">{{ $t("Cancel") }}</b-button>
<b-button class="btn btn-sm btn-danger shadow-none px-1" @click="confirmDelete()">{{ $t("Confirm") }}</b-button>
</b-col>
</b-row>
</b-popover>
</span>
</template>
<script>
import { ApiMixin, StandardToasts } from "@/utils/utils"
export default {
name: "ShoppingBadge",
props: {
item: { type: Object },
},
mixins: [ApiMixin],
data() {
return {
shopping: false,
}
},
mounted() {
// let random = [true, false,]
this.shopping = this.item?.shopping //?? random[Math.floor(Math.random() * random.length)]
},
computed: {
DeleteConfirmation() {
return this.$t("DeleteShoppingConfirm", { food: this.item.name })
},
ShowConfirmation() {
if (this.shopping) {
return "shopping" + this.item.id
} else {
return ""
}
},
},
watch: {
"item.shopping": function (newVal, oldVal) {
this.shopping = newVal
},
},
methods: {
addShopping() {
if (this.shopping) {
return
} // if item already in shopping list, excution handled after confirmation
let params = {
id: this.item.id,
amount: 1,
}
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then((result) => {
this.shopping = true
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
cancelDelete() {
this.$refs["shopping" + this.item.id].$emit("close")
},
confirmDelete() {
let params = {
id: this.item.id,
_delete: "true",
}
this.genericAPI(this.Models.FOOD, this.Actions.SHOPPING, params).then(() => {
this.shopping = false
this.$refs["shopping" + this.item.id].$emit("close")
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
},
},
}
</script>

View File

@ -0,0 +1,62 @@
<template>
<div>
<a v-if="!button" class="dropdown-item" @click="clipboard"><i :class="icon"></i> {{ label }}</a>
<b-button v-if="button" @click="clipboard">{{ label }}</b-button>
</div>
</template>
<script>
import { makeToast } from "@/utils/utils"
export default {
name: "CopyToClipboard",
props: {
items: { type: Array },
icon: { type: String },
label: { type: String },
button: { type: Boolean, default: false },
settings: { type: Object },
format: { type: String, default: "delim" },
},
methods: {
clipboard: function () {
let text = ""
switch (this.format) {
case "delim":
text = this.delimited()
break
case "table":
text = this.table()
break
}
navigator.clipboard.writeText(text).then(makeToast(this.$t("Success"), this.$t("SuccessClipboard"), "success"))
},
delimited: function () {
let csvContent = ""
let delim = this.settings.csv_delim || ","
let prefix = this.settings.csv_prefix || ""
csvContent += [prefix + Object.keys(this.items[0]).join(delim), ...this.items.map((x) => prefix + Object.values(x).join(delim))].join("\n").replace(/(^\[)|(\]$)/gm, "")
return csvContent
},
table: function () {
let table = ""
let delim = "|"
table += [
delim + Object.keys(this.items[0]).join(delim) + delim,
delim +
Object.keys(this.items[0])
.map((x) => {
return ":---"
})
.join(delim) +
delim,
...this.items.map((x) => delim + Object.values(x).join(delim) + delim),
]
.join("\n")
.replace(/(^\[)|(\]$)/gm, "")
return table
},
},
}
</script>

View File

@ -0,0 +1,33 @@
<template>
<div>
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
</div>
</template>
<script>
export default {
name: "DownloadCSV",
props: {
items: { type: Array },
name: { type: String },
icon: { type: String },
label: { type: String },
button: { type: Boolean, default: false },
delim: { type: String, default: "," },
},
methods: {
downloadFile() {
let csvContent = "data:text/csv;charset=utf-8,"
csvContent += [Object.keys(this.items[0]).join(this.delim), ...this.items.map((x) => Object.values(x).join(this.delim))].join("\n").replace(/(^\[)|(\]$)/gm, "")
const data = encodeURI(csvContent)
const link = document.createElement("a")
link.setAttribute("href", data)
link.setAttribute("download", "export.csv")
link.click()
},
},
}
</script>

View File

@ -0,0 +1,32 @@
<template>
<div>
<a v-if="!button" class="dropdown-item" @click="downloadFile"><i :class="icon"></i> {{ label }}</a>
<b-button v-if="button" @click="downloadFile">{{ label }}</b-button>
</div>
</template>
<script>
import html2pdf from "html2pdf.js"
export default {
name: "DownloadPDF",
props: {
dom: { type: String },
name: { type: String },
icon: { type: String },
label: { type: String },
button: { type: Boolean, default: false },
},
methods: {
downloadFile() {
const doc = document.querySelector(this.dom)
var options = {
margin: 1,
filename: this.name,
}
html2pdf().from(doc).set(options).save()
},
},
}
</script>

View File

@ -1,127 +1,118 @@
<template>
<div
class="context-menu"
ref="popper"
v-show="isVisible"
tabindex="-1"
v-click-outside="close"
@contextmenu.capture.prevent>
<ul class="dropdown-menu" role="menu">
<slot :contextData="contextData" name="menu"/>
</ul>
</div>
<div class="context-menu" ref="popper" v-show="isVisible" tabindex="-1" v-click-outside="close" @contextmenu.capture.prevent>
<ul class="dropdown-menu" role="menu">
<slot :contextData="contextData" name="menu" />
</ul>
</div>
</template>
<script>
import Popper from 'popper.js';
import Popper from "popper.js"
Popper.Defaults.modifiers.computeStyle.gpuAcceleration = false
import ClickOutside from 'vue-click-outside'
import ClickOutside from "vue-click-outside"
export default {
name: "ContextMenu.vue",
props: {
boundariesElement: {
type: String,
default: 'body',
},
},
components: {},
data() {
return {
opened: false,
contextData: {},
};
},
directives: {
ClickOutside,
},
computed: {
isVisible() {
return this.opened;
},
},
methods: {
open(evt, contextData) {
this.opened = true;
this.contextData = contextData;
if (this.popper) {
this.popper.destroy();
}
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
placement: 'right-start',
modifiers: {
preventOverflow: {
boundariesElement: document.querySelector(this.boundariesElement),
},
name: "ContextMenu.vue",
props: {
boundariesElement: {
type: String,
default: "body",
},
});
this.$nextTick(() => {
this.popper.scheduleUpdate();
});
},
close() {
this.opened = false;
this.contextData = null;
},
referenceObject(evt) {
const left = evt.clientX;
const top = evt.clientY;
const right = left + 1;
const bottom = top + 1;
const clientWidth = 1;
const clientHeight = 1;
function getBoundingClientRect() {
components: {},
data() {
return {
left,
top,
right,
bottom,
};
}
const obj = {
getBoundingClientRect,
clientWidth,
clientHeight,
};
return obj;
opened: false,
contextData: {},
}
},
},
beforeUnmount() {
if (this.popper !== undefined) {
this.popper.destroy();
}
},
};
directives: {
ClickOutside,
},
computed: {
isVisible() {
return this.opened
},
},
methods: {
open(evt, contextData) {
this.opened = true
this.contextData = contextData
if (this.popper) {
this.popper.destroy()
}
this.popper = new Popper(this.referenceObject(evt), this.$refs.popper, {
placement: "right-start",
modifiers: {
preventOverflow: {
boundariesElement: document.querySelector(this.boundariesElement),
},
},
})
this.$nextTick(() => {
this.popper.scheduleUpdate()
})
},
close() {
this.opened = false
this.contextData = null
},
referenceObject(evt) {
const left = evt.clientX
const top = evt.clientY
const right = left + 1
const bottom = top + 1
const clientWidth = 1
const clientHeight = 1
function getBoundingClientRect() {
return {
left,
top,
right,
bottom,
}
}
const obj = {
getBoundingClientRect,
clientWidth,
clientHeight,
}
return obj
},
},
beforeUnmount() {
if (this.popper !== undefined) {
this.popper.destroy()
}
},
}
</script>
<style scoped>
.context-menu {
position: fixed;
z-index: 999;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px 0 #eee;
position: fixed;
z-index: 999;
overflow: hidden;
background: #fff;
border-radius: 4px;
box-shadow: 0 1px 4px 0 #eee;
}
.context-menu:focus {
outline: none;
outline: none;
}
.context-menu ul {
padding: 0px;
margin: 0px;
padding: 0px;
margin: 0px;
}
.dropdown-menu {
display: block;
position: relative;
display: block;
position: relative;
}
</style>

View File

@ -1,16 +1,13 @@
<template>
<li @click="$emit('click', $event)" role="presentation">
<slot/>
</li>
<li @click="$emit('click', $event)" role="presentation">
<slot />
</li>
</template>
<script>
export default {
name: "ContextMenuItem.vue",
name: "ContextMenuItem.vue",
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@ -0,0 +1,38 @@
<template>
<span>
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
<template #button-content>
<i class="fas fa-ellipsis-v"></i>
</template>
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit"> <i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }} </b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete"> <i class="fas fa-trash-alt fa-fw"></i> {{ $t("Delete") }} </b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'add-shopping')" v-if="show_shopping">
<i class="fas fa-cart-plus fa-fw"></i> {{ $t("Add_to_Shopping") }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'add-onhand')" v-if="show_onhand"> <i class="fas fa-clipboard-check fa-fw"></i> {{ $t("OnHand") }} </b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move"> <i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t("Move") }} </b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')"> <i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t("Merge") }} </b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
<i class="fas fa-robot fa-fw"></i> {{ $t("Merge") }} & {{ $t("Automate") }} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
</b-dropdown-item>
</b-dropdown>
</span>
</template>
<script>
export default {
name: "GenericContextMenu",
props: {
show_edit: { type: Boolean, default: true },
show_delete: { type: Boolean, default: true },
show_move: { type: Boolean, default: false },
show_merge: { type: Boolean, default: false },
show_shopping: { type: Boolean, default: false },
show_onhand: { type: Boolean, default: false },
},
}
</script>

View File

@ -0,0 +1,52 @@
<template>
<!-- <b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button> -->
<span>
<b-dropdown variant="link" toggle-class="text-decoration-none text-dark shadow-none" no-caret style="boundary: window">
<template #button-content>
<i class="fas fa-chevron-down"></i>
</template>
<b-dropdown-item :href="resolveDjangoUrl('list_food')"> <i class="fas fa-leaf fa-fw"></i> {{ Models["FOOD"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')"> <i class="fas fa-tags fa-fw"></i> {{ Models["KEYWORD"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_unit')"> <i class="fas fa-balance-scale fa-fw"></i> {{ Models["UNIT"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')"> <i class="fas fa-store-alt fa-fw"></i> {{ Models["SUPERMARKET"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')"> <i class="fas fa-cubes fa-fw"></i> {{ Models["SHOPPING_CATEGORY"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_automation')"> <i class="fas fa-robot fa-fw"></i> {{ Models["AUTOMATION"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')"> <i class="fas fa-file fa-fw"></i> {{ Models["USERFILE"].name }} </b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_step')"> <i class="fas fa-puzzle-piece fa-fw"></i>{{ Models["STEP"].name }} </b-dropdown-item>
</b-dropdown>
</span>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import { Models } from "@/utils/models"
import { ResolveUrlMixin } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
name: "ModelMenu",
mixins: [ResolveUrlMixin],
data() {
return {
Models: Models,
}
},
mounted() {},
methods: {
gotoURL: function (model) {
return
},
},
}
</script>

View File

@ -1,42 +0,0 @@
<template>
<span>
<b-dropdown variant="link" toggle-class="text-decoration-none" right no-caret style="boundary:window">
<template #button-content>
<i class="fas fa-ellipsis-v" ></i>
</template>
<b-dropdown-item v-on:click="$emit('item-action', 'edit')" v-if="show_edit">
<i class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'delete')" v-if="show_delete">
<i class="fas fa-trash-alt fa-fw"></i> {{ $t('Delete') }}
</b-dropdown-item>
<b-dropdown-item v-on:click="$emit('item-action', 'move')" v-if="show_move">
<i class="fas fa-expand-arrows-alt fa-fw"></i> {{ $t('Move') }}
</b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge')">
<i class="fas fa-compress-arrows-alt fa-fw"></i> {{ $t('Merge') }}
</b-dropdown-item>
<b-dropdown-item v-if="show_merge" v-on:click="$emit('item-action', 'merge-automate')">
<i class="fas fa-robot fa-fw"></i> {{$t('Merge')}} & {{$t('Automate')}} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
</b-dropdown-item>
</b-dropdown>
</span>
</template>
<script>
export default {
name: 'GenericContextMenu',
props: {
show_edit: {type: Boolean, default: true},
show_delete: {type: Boolean, default: true},
show_move: {type: Boolean, default: false},
show_merge: {type: Boolean, default: false},
}
}
</script>

View File

@ -1,262 +1,296 @@
<template>
<div row style="margin: 4px">
<!-- @[useDrag&&`dragover`] <== this syntax completely shuts off draggable -->
<b-card no-body d-flex flex-column :class="{'border border-primary' : over, 'shake': isError}"
:style="{'cursor:grab' : useDrag}"
:draggable="useDrag"
@[useDrag&&`dragover`].prevent
@[useDrag&&`dragenter`].prevent
@[useDrag&&`dragstart`]="handleDragStart($event)"
@[useDrag&&`dragenter`]="handleDragEnter($event)"
@[useDrag&&`dragleave`]="handleDragLeave($event)"
@[useDrag&&`drop`]="handleDragDrop($event)">
<b-row no-gutters >
<b-col no-gutters class="col-sm-3">
<b-card-img-lazy style="object-fit: cover; height: 6em;" :src="item_image" v-bind:alt="$t('Recipe_Image')"></b-card-img-lazy>
</b-col>
<b-col no-gutters class="col-sm-9">
<b-card-body class="m-0 py-0">
<b-card-text class=" h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
<h5 class="m-0 mt-1 text-truncate">{{ item[title] }}</h5>
<div class= "m-0 text-truncate">{{ item[subtitle] }}</div>
<!-- <span>{{this_item[itemTags.field]}}</span> -->
<generic-pill v-for="x in itemTags" :key="x.field"
:item_list="item[x.field]"
:label="x.label"
:color="x.color"/>
<generic-ordered-pill v-for="x in itemOrderedTags" :key="x.field"
:item_list="item[x.field]"
:label="x.label"
:color="x.color"
:field="x.field"
:item="item"
@finish-action="finishAction"/>
<div class="mt-auto mb-1" align="right">
<span v-if="item[child_count]" class="mx-2 btn btn-link btn-sm"
style="z-index: 800;" v-on:click="$emit('item-action',{'action':'get-children','source':item})">
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>
<div v-else>{{ text.hide_children }}</div>
</span>
<span v-if="item[recipe_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800;"
v-on:click="$emit('item-action',{'action':'get-recipes','source':item})">
<div v-if="!item.show_recipes">{{ item[recipe_count] }} {{$t('Recipes')}}</div>
<div v-else>{{$t('Hide_Recipes')}}</div>
</span>
</div>
</b-card-text>
</b-card-body>
</b-col>
<div class="card-img-overlay justify-content-right h-25 m-0 p-0 text-right">
<badges :item="item" :model="model"/>
<generic-context-menu class="p-0"
:show_merge="useMerge"
:show_move="useMove"
@item-action="$emit('item-action', {'action': $event, 'source': item})">
</generic-context-menu>
<div row style="margin: 4px">
<!-- @[useDrag&&`dragover`] <== this syntax completely shuts off draggable -->
<b-card
no-body
d-flex
flex-column
:class="{ 'border border-primary': over, shake: isError }"
:style="{ 'cursor:grab': useDrag }"
:draggable="useDrag"
@[useDrag&&`dragover`].prevent
@[useDrag&&`dragenter`].prevent
@[useDrag&&`dragstart`]="handleDragStart($event)"
@[useDrag&&`dragenter`]="handleDragEnter($event)"
@[useDrag&&`dragleave`]="handleDragLeave($event)"
@[useDrag&&`drop`]="handleDragDrop($event)"
>
<b-row no-gutters>
<b-col no-gutters class="col-sm-3">
<b-card-img-lazy style="object-fit: cover; height: 6em" :src="item_image" v-bind:alt="$t('Recipe_Image')"></b-card-img-lazy>
</b-col>
<b-col no-gutters class="col-sm-9">
<b-card-body class="m-0 py-0">
<b-card-text class="h-100 my-0 d-flex flex-column" style="text-overflow: ellipsis">
<h5 class="m-0 mt-1 text-truncate">{{ item[title] }}</h5>
<div class="m-0 text-truncate">{{ item[subtitle] }}</div>
<div class="m-0 text-truncate small text-muted" v-if="getFullname">{{ getFullname }}</div>
<generic-pill v-for="x in itemTags" :key="x.field" :item_list="item[x.field]" :label="x.label" :color="x.color" />
<generic-ordered-pill
v-for="x in itemOrderedTags"
:key="x.field"
:item_list="item[x.field]"
:label="x.label"
:color="x.color"
:field="x.field"
:item="item"
@finish-action="finishAction"
/>
<div class="mt-auto mb-1" align="right">
<span v-if="item[child_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800" v-on:click="$emit('item-action', { action: 'get-children', source: item })">
<div v-if="!item.show_children">{{ item[child_count] }} {{ itemName }}</div>
<div v-else>{{ text.hide_children }}</div>
</span>
<span v-if="item[recipe_count]" class="mx-2 btn btn-link btn-sm" style="z-index: 800" v-on:click="$emit('item-action', { action: 'get-recipes', source: item })">
<div v-if="!item.show_recipes">{{ item[recipe_count] }} {{ $t("Recipes") }}</div>
<div v-else>{{ $t("Hide_Recipes") }}</div>
</span>
</div>
</b-card-text>
</b-card-body>
</b-col>
<div class="card-img-overlay justify-content-right h-25 m-0 p-0 text-right">
<badges :item="item" :model="model" />
<generic-context-menu
v-if="show_context_menu"
class="p-0"
:show_merge="useMerge"
:show_move="useMove"
:show_shopping="useShopping"
:show_onhand="useOnhand"
@item-action="$emit('item-action', { action: $event, source: item })"
>
</generic-context-menu>
</div>
</b-row>
</b-card>
<!-- recursively add child cards -->
<div class="row" v-if="item.show_children">
<div class="col-md-10 offset-md-2">
<generic-horizontal-card v-for="child in item[children]" v-bind:key="child.id" :item="child" :model="model" @item-action="$emit('item-action', $event)"> </generic-horizontal-card>
</div>
</div>
</b-row>
</b-card>
<!-- recursively add child cards -->
<div class="row" v-if="item.show_children">
<div class="col-md-10 offset-md-2">
<generic-horizontal-card v-for="child in item[children]" v-bind:key="child.id"
:item="child"
:model="model"
@item-action="$emit('item-action', $event)">
</generic-horizontal-card>
</div>
</div>
<!-- conditionally view recipes -->
<div class="row" v-if="item.show_recipes">
<div class="col-md-10 offset-md-2">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;">
<recipe-card v-for="r in item[recipes]"
v-bind:key="r.id"
:recipe="r">
</recipe-card>
<!-- conditionally view recipes -->
<div class="row" v-if="item.show_recipes">
<div class="col-md-10 offset-md-2">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-gap: 1rem">
<recipe-card v-for="r in item[recipes]" v-bind:key="r.id" :recipe="r"> </recipe-card>
</div>
</div>
</div>
</div>
<!-- this should be made a generic component, would also require mixin for functions that generate the popup and put in parent container-->
<b-list-group ref="tooltip" variant="light" v-show="show_menu" v-on-clickaway="closeMenu" style="z-index: 9999; cursor: pointer">
<b-list-group-item
v-if="useMove"
action
v-on:click="
$emit('item-action', { action: 'move', target: item, source: source })
closeMenu()
"
>
<i class="fas fa-expand-arrows-alt fa-fw"></i> <b>{{ $t("Move") }}</b
>: <span v-html="$t('move_confirmation', { child: source.name, parent: item.name })"></span>
</b-list-group-item>
<b-list-group-item
v-if="useMerge"
action
v-on:click="
$emit('item-action', { action: 'merge', target: item, source: source })
closeMenu()
"
>
<i class="fas fa-compress-arrows-alt fa-fw"></i> <b>{{ $t("Merge") }}</b
>: <span v-html="$t('merge_confirmation', { source: source.name, target: item.name })"></span>
</b-list-group-item>
<b-list-group-item
v-if="useMerge"
action
v-on:click="
$emit('item-action', { action: 'merge-automate', target: item, source: source })
closeMenu()
"
>
<i class="fas fa-robot fa-fw"></i> <b>{{ $t("Merge") }} & {{ $t("Automate") }}</b
>: <span v-html="$t('merge_confirmation', { source: source.name, target: item.name })"></span> {{ $t("create_rule") }}
<b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')">BETA</b-badge>
</b-list-group-item>
<b-list-group-item action v-on:click="closeMenu()">
<i class="fas fa-times fa-fw"></i> <b>{{ $t("Cancel") }}</b>
</b-list-group-item>
</b-list-group>
</div>
<!-- this should be made a generic component, would also require mixin for functions that generate the popup and put in parent container-->
<b-list-group ref="tooltip" variant="light" v-show="show_menu" v-on-clickaway="closeMenu" style="z-index:9999; cursor:pointer">
<b-list-group-item v-if="useMove" action v-on:click="$emit('item-action',{'action': 'move', 'target': item, 'source': source}); closeMenu()">
<i class="fas fa-expand-arrows-alt fa-fw"></i> <b>{{$t('Move')}}</b>: <span v-html="$t('move_confirmation', {'child': source.name,'parent':item.name})"></span>
</b-list-group-item>
<b-list-group-item v-if="useMerge" action v-on:click="$emit('item-action',{'action': 'merge', 'target': item, 'source': source}); closeMenu()">
<i class="fas fa-compress-arrows-alt fa-fw"></i> <b>{{$t('Merge')}}</b>: <span v-html="$t('merge_confirmation', {'source': source.name,'target':item.name})"></span>
</b-list-group-item>
<b-list-group-item v-if="useMerge" action v-on:click="$emit('item-action',{'action': 'merge-automate', 'target': item, 'source': source}); closeMenu()">
<i class="fas fa-robot fa-fw"></i> <b>{{$t('Merge')}} & {{$t('Automate')}}</b>: <span v-html="$t('merge_confirmation', {'source': source.name,'target':item.name})"></span> {{$t('create_rule')}} <b-badge v-b-tooltip.hover :title="$t('warning_feature_beta')" >BETA</b-badge>
</b-list-group-item>
<b-list-group-item action v-on:click="closeMenu()">
<i class="fas fa-times fa-fw"></i> <b>{{$t('Cancel')}}</b>
</b-list-group-item>
<!-- TODO add to shopping list -->
<!-- TODO add to and/or manage pantry -->
</b-list-group>
</div>
</template>
<script>
import GenericContextMenu from "@/components/GenericContextMenu";
import Badges from "@/components/Badges";
import GenericPill from "@/components/GenericPill";
import GenericOrderedPill from "@/components/GenericOrderedPill";
import RecipeCard from "@/components/RecipeCard";
import { mixin as clickaway } from 'vue-clickaway';
import { createPopper } from '@popperjs/core';
import GenericContextMenu from "@/components/ContextMenu/GenericContextMenu"
import Badges from "@/components/Badges"
import GenericPill from "@/components/GenericPill"
import GenericOrderedPill from "@/components/GenericOrderedPill"
import RecipeCard from "@/components/RecipeCard"
import { mixin as clickaway } from "vue-clickaway"
import { createPopper } from "@popperjs/core"
export default {
name: "GenericHorizontalCard",
components: { GenericContextMenu, RecipeCard, Badges, GenericPill, GenericOrderedPill},
mixins: [clickaway],
props: {
item: {type: Object},
model: {type: Object},
title: {type: String, default: 'name'}, // this and the following props need to be moved to model.js and made computed values
subtitle: {type: String, default: 'description'},
child_count: {type: String, default: 'numchild'},
children: {type: String, default: 'children'},
recipe_count: {type: String, default: 'numrecipe'},
recipes: {type: String, default: 'recipes'}
},
data() {
return {
item_image: '',
over: false,
show_menu: false,
dragMenu: undefined,
isError: false,
source: {'id': undefined, 'name': undefined},
target: {'id': undefined, 'name': undefined},
text: {
'hide_children': '',
},
}
},
mounted() {
this.item_image = this.item?.image ?? window.IMAGE_PLACEHOLDER
this.dragMenu = this.$refs.tooltip
this.text.hide_children = this.$t('Hide_' + this.itemName)
},
computed: {
itemName: function() {
return this.model?.name ?? "You Forgot To Set Model Name in model.js"
name: "GenericHorizontalCard",
components: { GenericContextMenu, RecipeCard, Badges, GenericPill, GenericOrderedPill },
mixins: [clickaway],
props: {
item: { type: Object },
model: { type: Object },
title: { type: String, default: "name" }, // this and the following props need to be moved to model.js and made computed values
subtitle: { type: String, default: "description" },
child_count: { type: String, default: "numchild" },
children: { type: String, default: "children" },
recipe_count: { type: String, default: "numrecipe" },
recipes: { type: String, default: "recipes" },
show_context_menu: { type: Boolean, default: true },
},
useMove: function() {
return (this.model?.['move'] ?? false) ? true : false
data() {
return {
item_image: "",
over: false,
show_menu: false,
dragMenu: undefined,
isError: false,
source: { id: undefined, name: undefined },
target: { id: undefined, name: undefined },
text: {
hide_children: "",
},
}
},
useMerge: function() {
return (this.model?.['merge'] ?? false) ? true : false
mounted() {
this.item_image = this.item?.image ?? window.IMAGE_PLACEHOLDER
this.dragMenu = this.$refs.tooltip
this.text.hide_children = this.$t("Hide_" + this.itemName)
},
useDrag: function() {
return this.useMove || this.useMerge
computed: {
itemName: function () {
return this.model?.name ?? "You Forgot To Set Model Name in model.js"
},
useMove: function () {
return this.model?.["move"] ?? false ? true : false
},
useMerge: function () {
return this.model?.["merge"] ?? false ? true : false
},
useShopping: function () {
return this.model?.["shop"] ?? false ? true : false
},
useOnhand: function () {
return this.model?.["onhand"] ?? false ? true : false
},
useDrag: function () {
return this.useMove || this.useMerge
},
itemTags: function () {
return this.model?.tags ?? []
},
itemOrderedTags: function () {
return this.model?.ordered_tags ?? []
},
getFullname: function () {
if (!this.item?.full_name?.includes(">")) {
return undefined
}
return this.item?.full_name
},
},
itemTags: function() {
return this.model?.tags ?? []
methods: {
handleDragStart: function (e) {
this.isError = false
e.dataTransfer.setData("source", JSON.stringify(this.item))
},
handleDragEnter: function (e) {
if (!e.currentTarget.contains(e.relatedTarget) && e.relatedTarget != null) {
this.over = true
}
},
handleDragLeave: function (e) {
if (!e.currentTarget.contains(e.relatedTarget)) {
this.over = false
}
},
handleDragDrop: function (e) {
let source = JSON.parse(e.dataTransfer.getData("source"))
if (source.id != this.item.id) {
this.source = source
let menuLocation = { getBoundingClientRect: this.generateLocation(e.clientX, e.clientY) }
this.show_menu = true
let popper = createPopper(menuLocation, this.dragMenu, {
placement: "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
rootBoundary: "document",
},
},
{
name: "flip",
options: {
fallbackPlacements: ["bottom-end", "top-start", "top-end", "left-start", "right-start"],
rootBoundary: "document",
},
},
],
})
popper.update()
this.over = false
this.$emit({ action: "drop", target: this.item, source: this.source })
} else {
this.isError = true
}
},
generateLocation: function (x = 0, y = 0) {
return () => ({
width: 0,
height: 0,
top: y,
right: x,
bottom: y,
left: x,
})
},
closeMenu: function () {
this.show_menu = false
},
finishAction: function (e) {
this.$emit("finish-action", e)
},
},
itemOrderedTags: function() {
return this.model?.ordered_tags ?? []
}
},
methods: {
handleDragStart: function(e) {
this.isError = false
e.dataTransfer.setData('source', JSON.stringify(this.item))
},
handleDragEnter: function(e) {
if (!e.currentTarget.contains(e.relatedTarget) && e.relatedTarget != null) {
this.over = true
}
},
handleDragLeave: function(e) {
if (!e.currentTarget.contains(e.relatedTarget)) {
this.over = false
}
},
handleDragDrop: function(e) {
let source = JSON.parse(e.dataTransfer.getData('source'))
if (source.id != this.item.id){
this.source = source
let menuLocation = {getBoundingClientRect: this.generateLocation(e.clientX, e.clientY),}
this.show_menu = true
let popper = createPopper(
menuLocation,
this.dragMenu,
{
placement: 'bottom-start',
modifiers: [
{
name: 'preventOverflow',
options: {
rootBoundary: 'document',
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-end', 'top-start', 'top-end', 'left-start', 'right-start'],
rootBoundary: 'document',
},
},
],
})
popper.update()
this.over = false
this.$emit({'action': 'drop', 'target': this.item, 'source': this.source})
} else {
this.isError = true
}
},
generateLocation: function (x = 0, y = 0) {
return () => ({
width: 0,
height: 0,
top: y,
right: x,
bottom: y,
left: x,
});
},
closeMenu: function(){
this.show_menu = false
},
finishAction: function(e){
this.$emit('finish-action', e)
}
}
}
</script>
<style scoped>
.shake {
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
animation: shake 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
</style>
</style>

View File

@ -1,123 +1,123 @@
<template>
<multiselect
v-model="selected_objects"
:options="objects"
:close-on-select="true"
:clear-on-select="true"
:hide-selected="multiple"
:preserve-search="true"
:placeholder="lookupPlaceholder"
:label="label"
track-by="id"
:multiple="multiple"
:taggable="allow_create"
:tag-placeholder="create_placeholder"
:loading="loading"
@search-change="search"
@input="selectionChanged"
@tag="addNew">
</multiselect>
<multiselect
v-model="selected_objects"
:options="objects"
:close-on-select="true"
:clear-on-select="true"
:hide-selected="multiple"
:preserve-search="true"
:placeholder="lookupPlaceholder"
:label="label"
track-by="id"
:multiple="multiple"
:taggable="allow_create"
:tag-placeholder="create_placeholder"
:loading="loading"
@search-change="search"
@input="selectionChanged"
@tag="addNew"
>
</multiselect>
</template>
<script>
import Multiselect from 'vue-multiselect'
import {ApiMixin} from "@/utils/utils";
import Multiselect from "vue-multiselect"
import { ApiMixin } from "@/utils/utils"
export default {
name: "GenericMultiselect",
components: {Multiselect},
mixins: [ApiMixin],
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
loading: false,
objects: [],
selected_objects: [],
}
},
props: {
placeholder: {type: String, default: undefined},
model: {
type: Object, default() {
return {}
}
},
label: {type: String, default: 'name'},
parent_variable: {type: String, default: undefined},
limit: {type: Number, default: 10,},
sticky_options: {
type: Array, default() {
return []
}
},
initial_selection: {
type: Array, default() {
return []
}
},
multiple: {type: Boolean, default: true},
allow_create: {type: Boolean, default: false}, // TODO: this will create option to add new drop-downs
create_placeholder: {type: String, default: 'You Forgot to Add a Tag Placeholder'},
},
watch: {
initial_selection: function (newVal, oldVal) { // watch it
this.selected_objects = newVal
},
},
mounted() {
this.search('')
this.selected_objects = this.initial_selection
},
computed: {
lookupPlaceholder() {
return this.placeholder || this.model.name || this.$t('Search')
},
},
methods: {
// this.genericAPI inherited from ApiMixin
search: function (query) {
let options = {
'page': 1,
'pageSize': 10,
'query': query
}
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) {
this.objects.forEach((item) => {
if ("default" in item) {
if (item.default) {
if(this.multiple) {
this.selected_objects = [item]
} else {
this.selected_objects = item
}
this.selectionChanged()
}
}
})
name: "GenericMultiselect",
components: { Multiselect },
mixins: [ApiMixin],
data() {
return {
// this.Models and this.Actions inherited from ApiMixin
loading: false,
objects: [],
selected_objects: [],
}
})
},
selectionChanged: function () {
this.$emit('change', {var: this.parent_variable, val: this.selected_objects})
props: {
placeholder: { type: String, default: undefined },
model: {
type: Object,
default() {
return {}
},
},
label: { type: String, default: "name" },
parent_variable: { type: String, default: undefined },
limit: { type: Number, default: 10 },
sticky_options: {
type: Array,
default() {
return []
},
},
initial_selection: {
type: Array,
default() {
return []
},
},
multiple: { type: Boolean, default: true },
allow_create: { type: Boolean, default: false },
create_placeholder: { type: String, default: "You Forgot to Add a Tag Placeholder" },
},
watch: {
initial_selection: function (newVal, oldVal) {
// watch it
this.selected_objects = newVal
},
},
mounted() {
this.search("")
this.selected_objects = this.initial_selection
},
computed: {
lookupPlaceholder() {
return this.placeholder || this.model.name || this.$t("Search")
},
},
methods: {
// this.genericAPI inherited from ApiMixin
search: function (query) {
let options = {
page: 1,
pageSize: 10,
query: query,
}
this.genericAPI(this.model, this.Actions.LIST, options).then((result) => {
this.objects = this.sticky_options.concat(result.data?.results ?? result.data)
if (this.selected_objects.length === 0 && this.initial_selection.length === 0 && this.objects.length > 0) {
this.objects.forEach((item) => {
if ("default" in item) {
if (item.default) {
if (this.multiple) {
this.selected_objects = [item]
} else {
this.selected_objects = item
}
this.selectionChanged()
}
}
})
}
})
},
selectionChanged: function () {
this.$emit("change", { var: this.parent_variable, val: this.selected_objects })
},
addNew(e) {
this.$emit("new", e)
// could refactor as Promise - seems unecessary
setTimeout(() => {
this.search("")
}, 750)
},
},
addNew(e) {
this.$emit('new', e)
// could refactor as Promise - seems unecessary
setTimeout(() => {
this.search('');
}, 750);
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style scoped>
</style>
<style scoped></style>

View File

@ -1,72 +1,75 @@
<template>
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500"
@change="orderChanged">
<span :key="k.id" v-for="k in itemList" class="pl-1">
<b-badge squared :variant="color"><i class="fas fa-grip-lines-vertical text-muted"></i><span class="ml-1">{{thisLabel(k)}}</span></b-badge>
</span>
<draggable v-if="itemList" v-model="this_list" tag="span" group="ordered_items" z-index="500" @change="orderChanged">
<span :key="k.id" v-for="k in itemList" class="pl-1">
<b-badge squared :variant="color"
><i class="fas fa-grip-lines-vertical text-muted"></i><span class="ml-1">{{ thisLabel(k) }}</span></b-badge
>
</span>
</draggable>
</template>
<script>
// you can't use this component with a horizontal card that is also draggable
import draggable from 'vuedraggable'
import draggable from "vuedraggable"
export default {
name: 'GenericOrderedPill',
components: {draggable},
props: {
item_list: {required: true, type: Array},
label: {type: String, default: 'name'},
color: {type: String, default: 'light'},
field: {type: String, required: true},
item: {type: Object},
},
data() {
return {
this_list: [],
}
},
computed: {
itemList: function() {
if(Array.isArray(this.this_list)) {
return this.this_list
} else if (!this.this_list?.name) {
return false
} else {
return [this.this_list]
}
name: "GenericOrderedPill",
components: { draggable },
props: {
item_list: {
type: Array,
default() {
return []
},
},
label: { type: String, default: "name" },
color: { type: String, default: "light" },
field: { type: String, required: true },
item: { type: Object },
},
},
mounted() {
this.this_list = this.item_list
},
watch: {
'item_list': function (newVal) {
this.this_list = newVal
}
},
methods: {
thisLabel: function (item) {
let fields = this.label.split('::')
let value = item
fields.forEach(x => {
value = value[x]
});
return value
data() {
return {
this_list: [],
}
},
orderChanged: function(e){
let order = 0
this.this_list.forEach(x => {
x['order'] = order
order++
})
let new_order = {...this.item}
new_order[this.field] = this.this_list
this.$emit('finish-action', {'action':'save','form_data': new_order })
computed: {
itemList: function() {
if (Array.isArray(this.this_list)) {
return this.this_list
} else if (!this.this_list?.name) {
return false
} else {
return [this.this_list]
}
},
},
mounted() {
this.this_list = this.item_list
},
watch: {
item_list: function(newVal) {
this.this_list = newVal
},
},
methods: {
thisLabel: function(item) {
let fields = this.label.split("::")
let value = item
fields.forEach((x) => {
value = value[x]
})
return value
},
orderChanged: function(e) {
let order = 0
this.this_list.forEach((x) => {
x["order"] = order
order++
})
let new_order = { ...this.item }
new_order[this.field] = this.this_list
this.$emit("finish-action", { action: "save", form_data: new_order })
},
},
}
}
</script>

View File

@ -10,12 +10,7 @@
export default {
name: "GenericPill",
props: {
item_list: {
type: Array,
default() {
return []
},
},
item_list: { type: Object },
label: { type: String, default: "name" },
color: { type: String, default: "light" },
},

View File

@ -1,88 +1,203 @@
<template>
<tr @click="$emit('checked-state-changed', ingredient)">
<template v-if="ingredient.is_header">
<td colspan="5">
<b>{{ ingredient.note }}</b>
</td>
</template>
<template v-else>
<td class="d-print-non" v-if="detailed">
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
</td>
<td>
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
</td>
<td>
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
</td>
<td>
<template v-if="ingredient.food !== null">
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null"
target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
<tr>
<template v-if="ingredient.is_header">
<td colspan="5" @click="done">
<b>{{ ingredient.note }}</b>
</td>
</template>
</td>
<td v-if="detailed">
<div v-if="ingredient.note">
<span v-b-popover.hover="ingredient.note"
class="d-print-none touchable"> <i class="far fa-comment"></i>
</span>
<!-- v-if="ingredient.note.length > 15" -->
<!-- <span v-else>-->
<!-- {{ ingredient.note }}-->
<!-- </span>-->
<div class="d-none d-print-block">
<i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}
</div>
</div>
</td>
</template>
</tr>
<template v-else>
<td class="d-print-non" v-if="detailed && !add_shopping_mode" @click="done">
<i class="far fa-check-circle text-success" v-if="ingredient.checked"></i>
<i class="far fa-check-circle text-primary" v-if="!ingredient.checked"></i>
</td>
<td class="text-nowrap" @click="done">
<span v-if="ingredient.amount !== 0" v-html="calculateAmount(ingredient.amount)"></span>
</td>
<td @click="done">
<span v-if="ingredient.unit !== null && !ingredient.no_amount">{{ ingredient.unit.name }}</span>
</td>
<td @click="done">
<template v-if="ingredient.food !== null">
<a :href="resolveDjangoUrl('view_recipe', ingredient.food.recipe.id)" v-if="ingredient.food.recipe !== null" target="_blank" rel="noopener noreferrer">{{ ingredient.food.name }}</a>
<span v-if="ingredient.food.recipe === null">{{ ingredient.food.name }}</span>
</template>
</td>
<td v-if="detailed && !show_shopping">
<div v-if="ingredient.note">
<span v-b-popover.hover="ingredient.note" class="d-print-none touchable">
<i class="far fa-comment"></i>
</span>
<div class="d-none d-print-block"><i class="far fa-comment-alt d-print-none"></i> {{ ingredient.note }}</div>
</div>
</td>
<td v-else-if="show_shopping" class="text-right text-nowrap">
<b-button
class="btn text-decoration-none fas fa-shopping-cart px-2 user-select-none"
variant="link"
v-b-popover.hover.click.blur.html.top="{ title: ShoppingPopover, variant: 'outline-dark' }"
:class="{
'text-success': shopping_status === true,
'text-muted': shopping_status === false,
'text-warning': shopping_status === null,
}"
/>
<span class="px-2">
<input type="checkbox" class="align-middle" v-model="shop" @change="changeShopping" />
</span>
<on-hand-badge :item="ingredient.food" />
</td>
</template>
</tr>
</template>
<script>
import {calculateAmount, ResolveUrlMixin} from "@/utils/utils";
import { calculateAmount, ResolveUrlMixin, ApiMixin } from "@/utils/utils"
import OnHandBadge from "@/components/Badges/OnHand"
export default {
name: 'IngredientComponent',
props: {
ingredient: Object,
ingredient_factor: {
type: Number,
default: 1,
name: "IngredientComponent",
components: { OnHandBadge },
props: {
ingredient: Object,
ingredient_factor: { type: Number, default: 1 },
detailed: { type: Boolean, default: true },
recipe_list: { type: Number }, // ShoppingListRecipe ID, to filter ShoppingStatus
show_shopping: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false },
shopping_list: {
type: Array,
default() {
return []
},
}, // list of unchecked ingredients in shopping list
},
mixins: [ResolveUrlMixin, ApiMixin],
data() {
return {
checked: false,
shopping_status: null, // in any shopping list: boolean + null=in shopping list, but not for this recipe
shopping_items: [],
shop: false, // in shopping list for this recipe: boolean
dirty: undefined,
}
},
watch: {
ShoppingListAndFilter: {
immediate: true,
handler(newVal, oldVal) {
// this whole sections is overly complicated
// trying to infer status of shopping for THIS recipe and THIS ingredient
// without know which recipe it is.
// If refactored:
// ## Needs to handle same recipe (multiple mealplans) being in shopping list multiple times
// ## Needs to handle same recipe being added as ShoppingListRecipe AND ingredients added from recipe as one-off
let filtered_list = this.shopping_list
// if a recipe list is provided, filter the shopping list
if (this.recipe_list) {
filtered_list = filtered_list.filter((x) => x.list_recipe == this.recipe_list)
}
// how many ShoppingListRecipes are there for this recipe?
let count_shopping_recipes = [...new Set(filtered_list.map((x) => x.list_recipe))].length
let count_shopping_ingredient = filtered_list.filter((x) => x.ingredient == this.ingredient.id).length
if (count_shopping_recipes >= 1) {
// This recipe is in the shopping list
this.shop = false // don't check any boxes until user selects a shopping list to edit
if (count_shopping_ingredient >= 1) {
this.shopping_status = true // ingredient is in the shopping list - probably (but not definitely, this ingredient)
} else if (this.ingredient.food.shopping) {
this.shopping_status = null // food is in the shopping list, just not for this ingredient/recipe
} else {
// food is not in any shopping list
this.shopping_status = false
}
} else {
// there are not recipes in the shopping list
// set default value
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
// mark checked if the food is in the shopping list for this ingredient/recipe
if (count_shopping_ingredient >= 1) {
// ingredient is in this shopping list (not entirely sure how this could happen?)
this.shopping_status = true
} else if (count_shopping_ingredient == 0 && this.ingredient.food.shopping) {
// food is in the shopping list, just not for this ingredient/recipe
this.shopping_status = null
} else {
// the food is not in any shopping list
this.shopping_status = false
}
}
if (this.add_shopping_mode) {
// if we are in add shopping mode (e.g. recipe_shopping_modal) start with all checks marked
// except if on_hand (could be if recipe too?)
this.shop = !this.ingredient?.food?.food_onhand && !this.ingredient?.food?.recipe
}
},
},
},
mounted() {},
computed: {
ShoppingListAndFilter() {
// hack to watch the shopping list and the recipe list at the same time
return this.shopping_list.map((x) => x.id).join(this.recipe_list)
},
ShoppingPopover() {
if (this.shopping_status == false) {
return this.$t("NotInShopping", { food: this.ingredient.food.name })
} else {
let list = this.shopping_list.filter((x) => x.food.id == this.ingredient.food.id)
let category = this.$t("Category") + ": " + this.ingredient?.food?.supermarket_category?.name ?? this.$t("Undefined")
let popover = []
list.forEach((x) => {
popover.push(
[
"<tr style='border-bottom: 1px solid #ccc'>",
"<td style='padding: 3px;'><em>",
x?.recipe_mealplan?.name ?? "",
"</em></td>",
"<td style='padding: 3px;'>",
x?.amount ?? "",
"</td>",
"<td style='padding: 3px;'>",
x?.unit?.name ?? "" + "</td>",
"<td style='padding: 3px;'>",
x?.food?.name ?? "",
"</td></tr>",
].join("")
)
})
return "<table class='table-small'><th colspan='4'>" + category + "</th>" + popover.join("") + "</table>"
}
},
},
methods: {
calculateAmount: function (x) {
return calculateAmount(x, this.ingredient_factor)
},
// sends parent recipe ingredient to notify complete has been toggled
done: function () {
this.$emit("checked-state-changed", this.ingredient)
},
// sends true/false to parent to save all ingredient shopping updates as a batch
changeShopping: function () {
this.$emit("add-to-shopping", { item: this.ingredient, add: this.shop })
},
},
detailed: {
type: Boolean,
default: true
}
},
mixins: [
ResolveUrlMixin
],
data() {
return {
checked: false
}
},
methods: {
calculateAmount: function (x) {
return calculateAmount(x, this.ingredient_factor)
}
}
}
</script>
<style scoped>
/* increase size of hover/touchable space without changing spacing */
.touchable {
padding-right: 2em;
padding-left: 2em;
margin-right: -2em;
margin-left: -2em;
padding-right: 2em;
padding-left: 2em;
margin-right: -2em;
margin-left: -2em;
}
</style>

View File

@ -0,0 +1,187 @@
<template>
<div :class="{ 'card border-primary no-border': header }">
<div :class="{ 'card-body': header }">
<div class="row" v-if="header">
<div class="col col-md-6">
<h4 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h4>
</div>
<div class="col col-md-6 text-right" v-if="header">
<h4>
<i v-if="show_shopping && ShoppingRecipes.length > 0" class="fas fa-trash text-danger px-2" @click="saveShopping(true)"></i>
<i v-if="show_shopping" class="fas fa-save text-success px-2" @click="saveShopping()"></i>
<i class="fas fa-shopping-cart px-2" @click="getShopping()"></i>
</h4>
</div>
</div>
<div class="row text-right" v-if="ShoppingRecipes.length > 1">
<div class="col col-md-6 offset-md-6 text-right">
<b-form-select v-model="selected_shoppingrecipe" :options="ShoppingRecipes" size="sm"></b-form-select>
</div>
</div>
<br v-if="header" />
<div class="row no-gutter">
<div class="col-md-12">
<table class="table table-sm">
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="s in steps">
<template v-for="i in s.ingredients">
<ingredient-component
:ingredient="i"
:ingredient_factor="ingredient_factor"
:key="i.id"
:show_shopping="show_shopping"
:shopping_list="shopping_list"
:add_shopping_mode="add_shopping_mode"
:detailed="detailed"
:recipe_list="selected_shoppingrecipe"
@checked-state-changed="$emit('checked-state-changed', $event)"
@add-to-shopping="addShopping($event)"
/>
</template>
</template>
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
</table>
</div>
</div>
</div>
</div>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import "bootstrap-vue/dist/bootstrap-vue.css"
import IngredientComponent from "@/components/IngredientComponent"
import { ApiMixin, StandardToasts } from "@/utils/utils"
Vue.use(BootstrapVue)
export default {
name: "IngredientCard",
mixins: [ApiMixin],
components: { IngredientComponent },
props: {
steps: {
type: Array,
default() {
return []
},
},
recipe: { type: Number },
ingredient_factor: { type: Number, default: 1 },
servings: { type: Number, default: 1 },
detailed: { type: Boolean, default: true },
header: { type: Boolean, default: false },
add_shopping_mode: { type: Boolean, default: false },
},
data() {
return {
show_shopping: false,
shopping_list: [],
update_shopping: [],
selected_shoppingrecipe: undefined,
}
},
computed: {
ShoppingRecipes() {
// returns open shopping lists associated with this recipe
let recipe_in_list = this.shopping_list
.map((x) => {
return { value: x?.list_recipe, text: x?.recipe_mealplan?.name, recipe: x?.recipe_mealplan?.recipe ?? 0, servings: x?.recipe_mealplan?.servings }
})
.filter((x) => x?.recipe == this.recipe)
return [...new Map(recipe_in_list.map((x) => [x["value"], x])).values()] // filter to unique lists
},
},
watch: {
ShoppingRecipes: function (newVal, oldVal) {
if (newVal.length === 0 || this.add_shopping_mode) {
this.selected_shoppingrecipe = undefined
} else if (newVal.length === 1) {
this.selected_shoppingrecipe = newVal[0].value
}
},
selected_shoppingrecipe: function (newVal, oldVal) {
this.update_shopping = this.shopping_list.filter((x) => x.list_recipe === newVal).map((x) => x.ingredient)
},
},
mounted() {
if (this.add_shopping_mode) {
this.show_shopping = true
this.getShopping(false)
}
},
methods: {
getShopping: function (toggle_shopping = true) {
if (toggle_shopping) {
this.show_shopping = !this.show_shopping
}
if (this.show_shopping) {
let ingredient_list = this.steps
.map((x) => x.ingredients)
.flat()
.map((x) => x.food.id)
let params = {
id: ingredient_list,
checked: "false",
}
this.genericAPI(this.Models.SHOPPING_LIST, this.Actions.LIST, params).then((result) => {
this.shopping_list = result.data
})
}
},
saveShopping: function (del_shopping = false) {
let servings = this.servings
if (del_shopping) {
servings = 0
}
let params = {
id: this.recipe,
list_recipe: this.selected_shoppingrecipe,
ingredients: this.update_shopping,
servings: servings,
}
this.genericAPI(this.Models.RECIPE, this.Actions.SHOPPING, params)
.then(() => {
if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
} else if (this.selected_shoppingrecipe) {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_UPDATE)
} else {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}
})
.then(() => {
if (!this.add_shopping_mode) {
return this.getShopping(false)
} else {
this.$emit("shopping-added")
}
})
.catch((err) => {
if (del_shopping) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_DELETE)
} else if (this.selected_shoppingrecipe) {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
} else {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
}
this.$emit("shopping-failed")
})
},
addShopping: function (e) {
// ALERT: this will all break if ingredients are re-used between recipes
if (e.add) {
this.update_shopping.push(e.item.id)
} else {
this.update_shopping = this.update_shopping.filter((x) => x !== e.item.id)
}
if (this.add_shopping_mode) {
this.$emit("add-to-shopping", e)
}
},
},
}
</script>

View File

@ -1,128 +1,132 @@
<template>
<div v-hover class="card cv-item meal-plan-card p-0" :key="value.id" :draggable="true"
:style="`top:${top};max-height:${item_height}`"
@dragstart="onDragItemStart(value, $event)"
@click="onClickItem(value, $event)"
:aria-grabbed="value == currentDragItem"
:class="value.classes"
@contextmenu.prevent="$emit('open-context-menu', $event, value)">
<div class="card-header p-1 text-center text-primary border-bottom-0" v-if="detailed"
:style="`background-color: ${background_color}`">
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null">{{
entry.entry.meal_type.icon
}}</span>
<span class="font-light d-none d-md-inline">{{ entry.entry.meal_type.name }}</span>
</div>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right p-0"
v-if="detailed">
<a>
<div style="position: static;">
<div class="dropdown b-dropdown position-static btn-group">
<button aria-haspopup="true" aria-expanded="false" type="button"
class="btn btn-link text-decoration-none text-body pr-2 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, value)"><i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
<div
v-hover
class="card cv-item meal-plan-card p-0"
:key="value.id"
:draggable="true"
:style="`top:${top};max-height:${item_height}`"
@dragstart="onDragItemStart(value, $event)"
@click="onClickItem(value, $event)"
:aria-grabbed="value == currentDragItem"
:class="value.classes"
@contextmenu.prevent="$emit('open-context-menu', $event, value)"
>
<div class="card-header p-1 text-center text-primary border-bottom-0" v-if="detailed" :style="`background-color: ${background_color}`">
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null">{{ entry.entry.meal_type.icon }}</span>
<span class="font-light d-none d-md-inline">{{ entry.entry.meal_type.name }}</span>
<span v-if="entry.entry.shopping" class="font-light"><i class="fas fa-shopping-cart fa-xs float-left" v-b-tooltip.hover.top :title="$t('in_shopping')" /></span>
</div>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right p-0" v-if="detailed">
<a>
<div style="position: static">
<div class="dropdown b-dropdown position-static btn-group">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn btn-link text-decoration-none text-body pr-2 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, value)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
</div>
</a>
</div>
<div class="card-header p-1 text-center" v-if="detailed" :style="`background-color: ${background_color}`">
<span class="font-light">{{ title }}</span>
</div>
<b-img fluid class="card-img-bottom" :src="entry.entry.recipe.image" v-if="hasRecipe && detailed"></b-img>
<b-img fluid class="card-img-bottom" :src="image_placeholder" v-if="detailed && ((!hasRecipe && entry.entry.note === '') || (hasRecipe && entry.entry.recipe.image === null))"></b-img>
<div class="card-body p-1" v-if="detailed && entry.entry.recipe == null" :style="`background-color: ${background_color}`">
<p>{{ entry.entry.note }}</p>
</div>
<div class="row p-1 flex-nowrap" v-if="!detailed" :style="`background-color: ${background_color}`">
<div class="col-2">
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null" v-b-tooltip.hover.left :title="entry.entry.meal_type.name">{{ entry.entry.meal_type.icon }}</span>
<span class="font-light text-center" v-if="entry.entry.meal_type.icon == null" v-b-tooltip.hover.left :title="entry.entry.meal_type.name"></span>
</div>
<div class="col-10 d-inline-block text-truncate" :style="`max-height:${item_height}`">
<span class="font-light">{{ title }}</span>
</div>
</div>
</a>
</div>
<div class="card-header p-1 text-center" v-if="detailed" :style="`background-color: ${background_color}`">
<span class="font-light">{{ title }}</span>
</div>
<b-img fluid class="card-img-bottom" :src="entry.entry.recipe.image" v-if="hasRecipe && detailed" ></b-img>
<b-img fluid class="card-img-bottom" :src="image_placeholder"
v-if="detailed && ((!hasRecipe && entry.entry.note === '') || (hasRecipe && entry.entry.recipe.image === null))"></b-img>
<div class="card-body p-1" v-if="detailed && entry.entry.recipe == null"
:style="`background-color: ${background_color}`">
<p>{{ entry.entry.note }}</p>
</div>
<div class="row p-1 flex-nowrap" v-if="!detailed" :style="`background-color: ${background_color}`">
<div class="col-2">
<span class="font-light text-center" v-if="entry.entry.meal_type.icon != null" v-b-tooltip.hover.left
:title=" entry.entry.meal_type.name">{{
entry.entry.meal_type.icon
}}</span>
<span class="font-light text-center" v-if="entry.entry.meal_type.icon == null" v-b-tooltip.hover.left
:title=" entry.entry.meal_type.name"></span>
</div>
<div class="col-10 d-inline-block text-truncate" :style="`max-height:${item_height}`">
<span class="font-light">{{ title }}</span>
</div>
</div>
</div>
</template>
<script>
export default {
name: "MealPlanCard.vue",
components: {},
props: {
value: Object,
weekStartDate: Date,
top: String,
detailed: Boolean,
item_height: String
},
data: function () {
return {
dateSelectionOrigin: null,
currentDragItem: null,
image_placeholder: window.IMAGE_PLACEHOLDER
}
},
computed: {
entry: function () {
return this.value.originalItem
name: "MealPlanCard.vue",
components: {},
props: {
value: Object,
weekStartDate: Date,
top: String,
detailed: Boolean,
item_height: String,
},
title: function () {
if (this.entry.entry.title != null && this.entry.entry.title !== '') {
return this.entry.entry.title
} else {
return this.entry.entry.recipe_name
}
data: function () {
return {
dateSelectionOrigin: null,
currentDragItem: null,
image_placeholder: window.IMAGE_PLACEHOLDER,
}
},
hasRecipe: function () {
return this.entry.entry.recipe != null;
mounted() {
console.log(this.value)
},
background_color: function () {
if (this.entry.entry.meal_type.color != null && this.entry.entry.meal_type.color !== '') {
return this.entry.entry.meal_type.color
} else {
return "#fff"
}
computed: {
entry: function () {
return this.value.originalItem
},
title: function () {
if (this.entry.entry.title != null && this.entry.entry.title !== "") {
return this.entry.entry.title
} else {
return this.entry.entry.recipe_name
}
},
hasRecipe: function () {
return this.entry.entry.recipe != null
},
background_color: function () {
if (this.entry.entry.meal_type.color != null && this.entry.entry.meal_type.color !== "") {
return this.entry.entry.meal_type.color
} else {
return "#fff"
}
},
},
},
methods: {
onDragItemStart(calendarItem, windowEvent) {
this.$emit("dragstart", calendarItem, windowEvent)
return true
methods: {
onDragItemStart(calendarItem, windowEvent) {
this.$emit("dragstart", calendarItem, windowEvent)
return true
},
onContextMenuOpen(calendarItem, windowEvent) {
this.$emit("dragstart", calendarItem, windowEvent)
return true
},
onClickItem(calendarItem, windowEvent) {
this.$emit("click-item", calendarItem)
return true
},
},
onContextMenuOpen(calendarItem, windowEvent) {
this.$emit("dragstart", calendarItem, windowEvent)
return true
directives: {
hover: {
inserted: (el) => {
el.addEventListener("mouseenter", () => {
el.classList.add("shadow")
})
el.addEventListener("mouseleave", () => {
el.classList.remove("shadow")
})
},
},
},
onClickItem(calendarItem, windowEvent) {
this.$emit("click-item", calendarItem)
return true
},
},
directives: {
hover: {
inserted: (el) => {
el.addEventListener('mouseenter', () => {
el.classList.add("shadow")
});
el.addEventListener('mouseleave', () => {
el.classList.remove("shadow")
});
}
}
}
}
</script>
<style scoped>
.meal-plan-card {
background-color: #fff;
background-color: #fff;
}
</style>
</style>

View File

@ -1,216 +1,236 @@
<template>
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
<div class="row">
<div class="col col-md-12">
<b-modal :id="modal_id" size="lg" :title="modal_title" hide-footer aria-label="" @show="showModal">
<div class="row">
<div class="col-6 col-lg-9">
<b-input-group>
<b-form-input id="TitleInput" v-model="entryEditing.title"
:placeholder="entryEditing.title_placeholder"
@change="missing_recipe = false"></b-form-input>
<b-input-group-append class="d-none d-lg-block">
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
</b-input-group-append>
</b-input-group>
<span class="text-danger" v-if="missing_recipe">{{ $t('Title_or_Recipe_Required') }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
</div>
<div class="col-6 col-lg-3">
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date">
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
</div>
<div class="col col-md-12">
<div class="row">
<div class="col col-md-12">
<div class="row">
<div class="col-6 col-lg-9">
<b-input-group>
<b-form-input id="TitleInput" v-model="entryEditing.title" :placeholder="entryEditing.title_placeholder" @change="missing_recipe = false"></b-form-input>
<b-input-group-append class="d-none d-lg-block">
<b-button variant="primary" @click="entryEditing.title = ''"><i class="fa fa-eraser"></i></b-button>
</b-input-group-append>
</b-input-group>
<span class="text-danger" v-if="missing_recipe">{{ $t("Title_or_Recipe_Required") }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_recipe">{{ $t("Title") }}</small>
</div>
<div class="col-6 col-lg-3">
<input type="date" id="DateInput" class="form-control" v-model="entryEditing.date" />
<small tabindex="-1" class="form-text text-muted">{{ $t("Date") }}</small>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-lg-6 col-xl-6">
<b-form-group>
<generic-multiselect
@change="selectRecipe"
:initial_selection="entryEditing_initial_recipe"
:label="'name'"
:model="Models.RECIPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Recipe')"
:limit="10"
:multiple="false"
></generic-multiselect>
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
</b-form-group>
<b-form-group class="mt-3">
<generic-multiselect
required
@change="selectMealType"
:label="'name'"
:model="Models.MEAL_TYPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Meal_Type')"
:limit="10"
:multiple="false"
:initial_selection="entryEditing_initial_meal_type"
:allow_create="true"
:create_placeholder="$t('Create_New_Meal_Type')"
@new="createMealType"
></generic-multiselect>
<span class="text-danger" v-if="missing_meal_type">{{ $t("Meal_Type_Required") }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
</b-form-group>
<b-form-group label-for="NoteInput" :description="$t('Note')" class="mt-3">
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note" :placeholder="$t('Note')"></textarea>
</b-form-group>
<b-input-group>
<b-form-input id="ServingsInput" v-model="entryEditing.servings" :placeholder="$t('Servings')"></b-form-input>
</b-input-group>
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
<b-form-group class="mt-3">
<generic-multiselect
required
@change="entryEditing.shared = $event.val"
parent_variable="entryEditing.shared"
:label="'username'"
:model="Models.USER_NAME"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Share')"
:limit="10"
:multiple="true"
:initial_selection="entryEditing.shared"
></generic-multiselect>
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
</b-form-group>
<b-input-group v-if="!autoMealPlan">
<b-form-checkbox id="AddToShopping" v-model="entryEditing.addshopping" />
<small tabindex="-1" class="form-text text-muted">{{ $t("AddToShopping") }}</small>
</b-input-group>
</div>
<div class="col-lg-6 d-none d-lg-block d-xl-block">
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null" :detailed="false"></recipe-card>
</div>
</div>
<div class="row mt-3 mb-3">
<div class="col-12">
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t("Delete") }} </b-button>
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t("Save") }}</b-button>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12 col-lg-6 col-xl-6">
<b-form-group>
<generic-multiselect
@change="selectRecipe"
:initial_selection="entryEditing_initial_recipe"
:label="'name'"
:model="Models.RECIPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Recipe')" :limit="10"
:multiple="false"></generic-multiselect>
<small tabindex="-1" class="form-text text-muted">{{ $t("Recipe") }}</small>
</b-form-group>
<b-form-group class="mt-3">
<generic-multiselect required
@change="selectMealType"
:label="'name'"
:model="Models.MEAL_TYPE"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Meal_Type')" :limit="10"
:multiple="false"
:initial_selection="entryEditing_initial_meal_type"
:allow_create="true"
:create_placeholder="$t('Create_New_Meal_Type')"
@new="createMealType"
></generic-multiselect>
<span class="text-danger" v-if="missing_meal_type">{{ $t('Meal_Type_Required') }}</span>
<small tabindex="-1" class="form-text text-muted" v-if="!missing_meal_type">{{ $t("Meal_Type") }}</small>
</b-form-group>
<b-form-group
label-for="NoteInput"
:description="$t('Note')" class="mt-3">
<textarea class="form-control" id="NoteInput" v-model="entryEditing.note"
:placeholder="$t('Note')"></textarea>
</b-form-group>
<b-input-group>
<b-form-input id="ServingsInput" v-model="entryEditing.servings"
:placeholder="$t('Servings')"></b-form-input>
</b-input-group>
<small tabindex="-1" class="form-text text-muted">{{ $t("Servings") }}</small>
<b-form-group class="mt-3">
<generic-multiselect required
@change="entryEditing.shared = $event.val" parent_variable="entryEditing.shared"
:label="'username'"
:model="Models.USER_NAME"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
v-bind:placeholder="$t('Share')" :limit="10"
:multiple="true"
:initial_selection="entryEditing.shared"
></generic-multiselect>
<small tabindex="-1" class="form-text text-muted">{{ $t("Share") }}</small>
</b-form-group>
</div>
<div class="col-lg-6 d-none d-lg-block d-xl-block">
<recipe-card :recipe="entryEditing.recipe" v-if="entryEditing.recipe != null"></recipe-card>
</div>
</div>
<div class="row mt-3 mb-3">
<div class="col-12">
<b-button variant="danger" @click="deleteEntry" v-if="allow_delete">{{ $t('Delete') }}
</b-button>
<b-button class="float-right" variant="primary" @click="editEntry">{{ $t('Save') }}</b-button>
</div>
</div>
</div>
</div>
</b-modal>
</b-modal>
</template>
<script>
import Vue from "vue";
import {BootstrapVue} from "bootstrap-vue";
import GenericMultiselect from "./GenericMultiselect";
import {ApiMixin} from "../utils/utils";
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import GenericMultiselect from "@/components/GenericMultiselect"
import { ApiMixin, getUserPreference } from "@/utils/utils"
const {ApiApiFactory} = require("@/utils/openapi/api");
const { ApiApiFactory } = require("@/utils/openapi/api")
const {StandardToasts} = require("@/utils/utils");
const { StandardToasts } = require("@/utils/utils")
Vue.use(BootstrapVue)
export default {
name: "MealPlanEditModal",
props: {
entry: Object,
entryEditing_initial_recipe: Array,
entryEditing_initial_meal_type: Array,
modal_title: String,
modal_id: {
type: String,
default: "edit-modal"
name: "MealPlanEditModal",
props: {
entry: Object,
entryEditing_initial_recipe: Array,
entryEditing_initial_meal_type: Array,
entryEditing_inital_servings: Number,
modal_title: String,
modal_id: {
type: String,
default: "edit-modal",
},
allow_delete: {
type: Boolean,
default: true,
},
},
allow_delete: {
type: Boolean,
default: true
}
},
mixins: [ApiMixin],
components: {
GenericMultiselect,
RecipeCard: () => import('@/components/RecipeCard.vue')
},
data() {
return {
entryEditing: {},
missing_recipe: false,
missing_meal_type: false,
default_plan_share: []
}
},
watch: {
entry: {
handler() {
this.entryEditing = Object.assign({}, this.entry)
},
deep: true
}
},
methods: {
showModal() {
let apiClient = new ApiApiFactory()
apiClient.listUserPreferences().then(result => {
if (this.entry.id === -1) {
this.entryEditing.shared = result.data[0].plan_share
mixins: [ApiMixin],
components: {
GenericMultiselect,
RecipeCard: () => import("@/components/RecipeCard.vue"),
},
data() {
return {
entryEditing: {},
missing_recipe: false,
missing_meal_type: false,
default_plan_share: [],
}
})
},
editEntry() {
this.missing_meal_type = false
this.missing_recipe = false
let cancel = false
if (this.entryEditing.meal_type == null) {
this.missing_meal_type = true
cancel = true
}
if (this.entryEditing.recipe == null && this.entryEditing.title === '') {
this.missing_recipe = true
cancel = true
}
if (!cancel) {
this.$bvModal.hide(`edit-modal`);
this.$emit('save-entry', this.entryEditing)
}
watch: {
entry: {
handler() {
this.entryEditing = Object.assign({}, this.entry)
if (this.entryEditing_inital_servings) {
this.entryEditing.servings = this.entryEditing_inital_servings
}
},
deep: true,
},
},
deleteEntry() {
this.$bvModal.hide(`edit-modal`);
this.$emit('delete-entry', this.entryEditing)
mounted: function () {},
computed: {
autoMealPlan: function () {
return getUserPreference("mealplan_autoadd_shopping")
},
},
selectMealType(event) {
this.missing_meal_type = false
if (event.val != null) {
this.entryEditing.meal_type = event.val;
} else {
this.entryEditing.meal_type = null;
}
},
selectShared(event) {
if (event.val != null) {
this.entryEditing.shared = event.val;
} else {
this.entryEditing.meal_type = null;
}
},
createMealType(event) {
if (event != "") {
let apiClient = new ApiApiFactory()
methods: {
showModal() {
let apiClient = new ApiApiFactory()
apiClient.createMealType({name: event}).then(e => {
this.$emit('reload-meal-types')
}).catch(error => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
apiClient.listUserPreferences().then((result) => {
if (this.entry.id === -1) {
this.entryEditing.shared = result.data[0].plan_share
}
})
},
editEntry() {
this.missing_meal_type = false
this.missing_recipe = false
let cancel = false
if (this.entryEditing.meal_type == null) {
this.missing_meal_type = true
cancel = true
}
if (this.entryEditing.recipe == null && this.entryEditing.title === "") {
this.missing_recipe = true
cancel = true
}
if (!cancel) {
this.$bvModal.hide(`edit-modal`)
this.$emit("save-entry", this.entryEditing)
}
},
deleteEntry() {
this.$bvModal.hide(`edit-modal`)
this.$emit("delete-entry", this.entryEditing)
},
selectMealType(event) {
this.missing_meal_type = false
if (event.val != null) {
this.entryEditing.meal_type = event.val
} else {
this.entryEditing.meal_type = null
}
},
selectShared(event) {
if (event.val != null) {
this.entryEditing.shared = event.val
} else {
this.entryEditing.meal_type = null
}
},
createMealType(event) {
if (event != "") {
let apiClient = new ApiApiFactory()
apiClient
.createMealType({ name: event })
.then((e) => {
this.$emit("reload-meal-types")
})
.catch((error) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_UPDATE)
})
}
},
selectRecipe(event) {
this.missing_recipe = false
if (event.val != null) {
this.entryEditing.recipe = event.val
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
this.entryEditing.servings = this.entryEditing.recipe.servings
} else {
this.entryEditing.recipe = null
this.entryEditing.title_placeholder = ""
this.entryEditing.servings = 1
}
},
},
selectRecipe(event) {
this.missing_recipe = false
if (event.val != null) {
this.entryEditing.recipe = event.val;
this.entryEditing.title_placeholder = this.entryEditing.recipe.name
this.entryEditing.servings = this.entryEditing.recipe.servings
} else {
this.entryEditing.recipe = null;
this.entryEditing.title_placeholder = ""
this.entryEditing.servings = 1
}
},
}
}
</script>
<style scoped>
</style>
<style scoped></style>

View File

@ -0,0 +1,122 @@
<template>
<div>
<b-modal class="modal" :id="`id_modal_add_book_${modal_id}`" :title="$t('Manage_Books')" :ok-title="$t('Add')"
:cancel-title="$t('Close')" @ok="addToBook()" @shown="loadBookEntries">
<ul class="list-group">
<li class="list-group-item d-flex justify-content-between align-items-center" v-for="be in this.recipe_book_list" v-bind:key="be.id">
{{ be.book_content.name }} <span class="btn btn-sm btn-danger" @click="removeFromBook(be)"><i class="fa fa-trash-alt"></i></span>
</li>
</ul>
<multiselect
style="margin-top: 1vh"
v-model="selected_book"
:options="books_filtered"
:taggable="true"
@tag="createBook"
v-bind:tag-placeholder="$t('Create')"
:placeholder="$t('Select_Book')"
label="name"
track-by="id"
id="id_books"
:multiple="false"
:loading="books_loading"
@search-change="loadBooks">
</multiselect>
</b-modal>
</div>
</template>
<script>
import Multiselect from 'vue-multiselect'
import moment from 'moment'
Vue.prototype.moment = moment
import Vue from "vue";
import {BootstrapVue} from "bootstrap-vue";
import {ApiApiFactory} from "@/utils/openapi/api";
import {makeStandardToast, StandardToasts} from "@/utils/utils";
Vue.use(BootstrapVue)
export default {
name: 'AddRecipeToBook',
components: {
Multiselect
},
props: {
recipe: Object,
modal_id: Number
},
data() {
return {
books: [],
books_loading: false,
recipe_book_list: [],
selected_book: null,
}
},
computed: {
books_filtered: function () {
let books_filtered = []
this.books.forEach(b => {
if (this.recipe_book_list.filter(e => e.book === b.id).length === 0) {
books_filtered.push(b)
}
})
return books_filtered
}
},
mounted() {
},
methods: {
loadBooks: function (query) {
this.books_loading = true
let apiFactory = new ApiApiFactory()
apiFactory.listRecipeBooks({query: {query: query}}).then(results => {
this.books = results.data.filter(e => this.recipe_book_list.indexOf(e) === -1)
this.books_loading = false
})
},
createBook: function (name) {
let apiFactory = new ApiApiFactory()
apiFactory.createRecipeBook({name: name}).then(r => {
this.books.push(r.data)
this.selected_book = r.data
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
addToBook: function () {
let apiFactory = new ApiApiFactory()
apiFactory.createRecipeBookEntry({book: this.selected_book.id, recipe: this.recipe.id}).then(r => {
this.recipe_book_list.push(r.data)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
},
removeFromBook: function (book_entry) {
let apiFactory = new ApiApiFactory()
apiFactory.destroyRecipeBookEntry(book_entry.id).then(r => {
this.recipe_book_list = this.recipe_book_list.filter(e => e.id !== book_entry.id)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_DELETE)
})
},
loadBookEntries: function () {
let apiFactory = new ApiApiFactory()
apiFactory.listRecipeBookEntrys({query: {recipe: this.recipe.id}}).then(r => {
this.recipe_book_list = r.data
this.loadBooks('')
})
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>

View File

@ -1,20 +1,18 @@
<template>
<div>
<b-modal :id="'modal_' + id" @hidden="cancelAction">
<template v-slot:modal-title
><h4>{{ form.title }}</h4></template
>
<template v-slot:modal-title>
<h4>{{ form.title }}</h4>
</template>
<div v-for="(f, i) in form.fields" v-bind:key="i">
<p v-if="f.type == 'instruction'">{{ f.label }}</p>
<!-- this lookup is single selection -->
<lookup-input v-if="f.type == 'lookup'" :form="f" :model="listModel(f.list)" @change="storeValue" />
<!-- TODO add ability to create new items associated with lookup -->
<!-- TODO: add multi-selection input list -->
<checkbox-input v-if="f.type == 'checkbox'" :label="f.label" :value="f.value" :field="f.field" />
<text-input v-if="f.type == 'text'" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
<choice-input v-if="f.type == 'choice'" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
<emoji-input v-if="f.type == 'emoji'" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
<file-input v-if="f.type == 'file'" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
<p v-if="visibleCondition(f, 'instruction')">{{ f.label }}</p>
<lookup-input v-if="visibleCondition(f, 'lookup')" :form="f" :model="listModel(f.list)" @change="storeValue" />
<checkbox-input class="mb-3" v-if="visibleCondition(f, 'checkbox')" :label="f.label" :value="f.value" :field="f.field" />
<text-input v-if="visibleCondition(f, 'text')" :label="f.label" :value="f.value" :field="f.field" :placeholder="f.placeholder" />
<choice-input v-if="visibleCondition(f, 'choice')" :label="f.label" :value="f.value" :field="f.field" :options="f.options" :placeholder="f.placeholder" />
<emoji-input v-if="visibleCondition(f, 'emoji')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
<file-input v-if="visibleCondition(f, 'file')" :label="f.label" :value="f.value" :field="f.field" @change="storeValue" />
<small-text v-if="visibleCondition(f, 'smalltext')" :value="f.value" />
</div>
<template v-slot:modal-footer>
@ -28,7 +26,7 @@
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
import { getForm } from "@/utils/utils"
import { getForm, formFunctions } from "@/utils/utils"
Vue.use(BootstrapVue)
@ -40,14 +38,20 @@ import TextInput from "@/components/Modals/TextInput"
import EmojiInput from "@/components/Modals/EmojiInput"
import ChoiceInput from "@/components/Modals/ChoiceInput"
import FileInput from "@/components/Modals/FileInput"
import SmallText from "@/components/Modals/SmallText"
export default {
name: "GenericModalForm",
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput },
components: { FileInput, CheckboxInput, LookupInput, TextInput, EmojiInput, ChoiceInput, SmallText },
mixins: [ApiMixin, ToastMixin],
props: {
model: { required: true, type: Object },
action: { type: Object },
action: {
type: Object,
default() {
return {}
},
},
item1: {
type: Object,
default() {
@ -84,6 +88,9 @@ export default {
show: function () {
if (this.show) {
this.form = getForm(this.model, this.action, this.item1, this.item2)
if (this.form?.form_function) {
this.form = formFunctions[this.form.form_function](this.form)
}
this.dirty = true
this.$bvModal.show("modal_" + this.id)
} else {
@ -245,6 +252,21 @@ export default {
apiClient.createAutomation(automation)
}
},
visibleCondition(field, field_type) {
let type_match = field?.type == field_type
let checks = true
if (type_match && field?.condition) {
if (field.condition?.condition === "exists") {
if ((this.item1[field.condition.field] != undefined) === field.condition.value) {
checks = true
} else {
checks = false
}
}
}
return type_match && checks
},
},
}
</script>

View File

@ -80,8 +80,7 @@ export default {
} else {
arrayValues = [{ id: -1, name: this_value }]
}
if (this.form?.ordered && this.first_run) {
if (this.form?.ordered && this.first_run && arrayValues.length > 0) {
return this.flattenItems(arrayValues)
} else {
return arrayValues

View File

@ -0,0 +1,177 @@
<template>
<div>
<b-modal :id="`shopping_${this.modal_id}`" hide-footer @show="loadRecipe">
<template v-slot:modal-title
><h4>{{ $t("Add_Servings_to_Shopping", { servings: servings }) }}</h4></template
>
<loading-spinner v-if="loading"></loading-spinner>
<div class="accordion" role="tablist" v-if="!loading">
<b-card no-body class="mb-1">
<b-card-header header-tag="header" class="p-1" role="tab">
<b-button block v-b-toggle.accordion-0 class="text-left" variant="outline-info">{{ recipe.name }}</b-button>
</b-card-header>
<b-collapse id="accordion-0" visible accordion="my-accordion" role="tabpanel">
<ingredients-card
:steps="steps"
:recipe="recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:show_shopping="true"
:add_shopping_mode="true"
:header="false"
@add-to-shopping="addShopping($event)"
/>
</b-collapse>
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="r in related_recipes">
<b-card no-body class="mb-1" :key="r.recipe.id">
<b-card-header header-tag="header" class="p-1" role="tab">
<b-button btn-sm block v-b-toggle="'accordion-' + r.recipe.id" class="text-left" variant="outline-primary">{{ r.recipe.name }}</b-button>
</b-card-header>
<b-collapse :id="'accordion-' + r.recipe.id" accordion="my-accordion" role="tabpanel">
<ingredients-card
:steps="r.steps"
:recipe="r.recipe.id"
:ingredient_factor="ingredient_factor"
:servings="servings"
:show_shopping="true"
:add_shopping_mode="true"
:header="false"
@add-to-shopping="addShopping($event)"
/>
</b-collapse>
</b-card>
</template>
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
</b-card>
</div>
<div class="row mt-3 mb-3">
<div class="col-12 text-right">
<b-button class="mx-2" variant="secondary" @click="$bvModal.hide(`shopping_${modal_id}`)">{{ $t("Cancel") }} </b-button>
<b-button class="mx-2" variant="success" @click="saveShopping">{{ $t("Save") }} </b-button>
</div>
</div>
</b-modal>
</div>
</template>
<script>
import Vue from "vue"
import { BootstrapVue } from "bootstrap-vue"
Vue.use(BootstrapVue)
const { ApiApiFactory } = require("@/utils/openapi/api")
import { StandardToasts } from "@/utils/utils"
import IngredientsCard from "@/components/IngredientsCard"
import LoadingSpinner from "@/components/LoadingSpinner"
export default {
name: "ShoppingModal",
components: { IngredientsCard, LoadingSpinner },
mixins: [],
props: {
recipe: { required: true, type: Object },
servings: { type: Number },
modal_id: { required: true, type: Number },
},
data() {
return {
loading: true,
steps: [],
recipe_servings: 0,
add_shopping: [],
related_recipes: [],
}
},
mounted() {},
computed: {
ingredient_factor: function () {
return this.servings / this.recipe.servings || this.recipe_servings
},
},
watch: {},
methods: {
loadRecipe: function () {
this.add_shopping = []
this.related_recipes = []
let apiClient = new ApiApiFactory()
apiClient
.retrieveRecipe(this.recipe.id)
.then((result) => {
this.steps = result.data.steps
// ALERT: this will all break if ingredients are re-used between recipes
// ALERT: this also doesn't quite work right if the same recipe appears multiple time in the related recipes
this.add_shopping = [
...this.add_shopping,
...this.steps
.map((x) => x.ingredients)
.flat()
.filter((x) => !x?.food?.food_onhand)
.map((x) => x.id),
]
this.recipe_servings = result.data?.servings
this.loading = false
})
.then(() => {
// get a list of related recipes
apiClient
.relatedRecipe(this.recipe.id)
.then((result) => {
return result.data
})
.then((related_recipes) => {
let promises = []
related_recipes.forEach((x) => {
promises.push(
apiClient.listSteps(x.id).then((recipe_steps) => {
this.related_recipes.push({
recipe: x,
steps: recipe_steps.data.results.filter((x) => x.ingredients.length > 0),
})
})
)
})
return Promise.all(promises)
})
.then(() => {
this.add_shopping = [
...this.add_shopping,
...this.related_recipes
.map((x) => x.steps)
.flat()
.map((x) => x.ingredients)
.flat()
.filter((x) => !x.food.override_ignore)
.map((x) => x.id),
]
})
})
},
addShopping: function (e) {
if (e.add) {
this.add_shopping.push(e.item.id)
} else {
this.add_shopping = this.add_shopping.filter((x) => x !== e.item.id)
}
},
saveShopping: function () {
// another choice would be to create ShoppingListRecipes for each recipe - this bundles all related recipe under the parent recipe
let shopping_recipe = {
id: this.recipe.id,
ingredients: this.add_shopping,
servings: this.servings,
}
let apiClient = new ApiApiFactory()
apiClient
.shoppingRecipe(this.recipe.id, shopping_recipe)
.then((result) => {
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((err) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
this.$bvModal.hide(`shopping_${this.modal_id}`)
},
},
}
</script>

View File

@ -0,0 +1,20 @@
<template>
<div class="small text-muted">
{{ value }}
</div>
</template>
<script>
export default {
name: "TextInput",
props: {
value: { type: String, default: "" },
},
data() {
return {}
},
mounted() {},
watch: {},
methods: {},
}
</script>

View File

@ -1,42 +1,34 @@
<template>
<div>
<b-form-group
v-bind:label="label"
class="mb-3">
<b-form-input
v-model="new_value"
type="string"
:placeholder="placeholder"
></b-form-input>
<b-form-group v-bind:label="label" class="mb-3">
<b-form-input v-model="new_value" type="text" :placeholder="placeholder"></b-form-input>
</b-form-group>
</div>
</template>
<script>
export default {
name: 'TextInput',
props: {
field: {type: String, default: 'You Forgot To Set Field Name'},
label: {type: String, default: 'Text Field'},
value: {type: String, default: ''},
placeholder: {type: String, default: 'You Should Add Placeholder Text'},
show_merge: {type: Boolean, default: false},
},
data() {
return {
new_value: undefined,
}
},
mounted() {
this.new_value = this.value
},
watch: {
'new_value': function () {
this.$root.$emit('change', this.field, this.new_value)
name: "TextInput",
props: {
field: { type: String, default: "You Forgot To Set Field Name" },
label: { type: String, default: "Text Field" },
value: { type: String, default: "" },
placeholder: { type: String, default: "You Should Add Placeholder Text" },
show_merge: { type: Boolean, default: false },
},
},
methods: {
}
data() {
return {
new_value: undefined,
}
},
mounted() {
this.new_value = this.value
},
watch: {
new_value: function () {
this.$root.$emit("change", this.field, this.new_value)
},
},
methods: {},
}
</script>
</script>

View File

@ -1,74 +0,0 @@
<template>
<!-- <b-button variant="link" size="sm" class="text-dark shadow-none"><i class="fas fa-chevron-down"></i></b-button> -->
<span>
<b-dropdown variant="link" toggle-class="text-decoration-none text-dark shadow-none" no-caret
style="boundary:window">
<template #button-content>
<i class="fas fa-chevron-down"></i>
</template>
<b-dropdown-item :href="resolveDjangoUrl('list_food')">
<i class="fas fa-leaf fa-fw"></i> {{ Models['FOOD'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_keyword')">
<i class="fas fa-tags fa-fw"></i> {{ Models['KEYWORD'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_unit')">
<i class="fas fa-balance-scale fa-fw"></i> {{ Models['UNIT'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket')">
<i class="fas fa-store-alt fa-fw"></i> {{ Models['SUPERMARKET'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_supermarket_category')">
<i class="fas fa-cubes fa-fw"></i> {{ Models['SHOPPING_CATEGORY'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_automation')">
<i class="fas fa-robot fa-fw"></i> {{ Models['AUTOMATION'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_user_file')">
<i class="fas fa-file fa-fw"></i> {{ Models['USERFILE'].name }}
</b-dropdown-item>
<b-dropdown-item :href="resolveDjangoUrl('list_step')">
<i class="fas fa-puzzle-piece fa-fw"></i>{{ Models['STEP'].name }}
</b-dropdown-item>
</b-dropdown>
</span>
</template>
<script>
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import {Models} from "@/utils/models";
import {ResolveUrlMixin} from "@/utils/utils";
Vue.use(BootstrapVue)
export default {
name: 'ModelMenu',
mixins: [ResolveUrlMixin],
data() {
return {
Models: Models
}
},
mounted() {
},
methods: {
gotoURL: function (model) {
return
}
}
}
</script>

View File

@ -1,158 +1,135 @@
<template>
<b-card no-body v-hover>
<a :href="clickUrl()">
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src=recipe_image
v-bind:alt="$t('Recipe_Image')"
top></b-card-img-lazy>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
<a>
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
</a>
</div>
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2"
v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i>
{{ recipe.working_time }} {{ $t('min') }}
</b-badge>
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i>
{{ recipe.waiting_time }} {{ $t('min') }}
</b-badge>
</div>
</a>
<b-card-body class="p-4">
<h6><a :href="clickUrl()">
<template v-if="recipe !== null">{{ recipe.name }}</template>
<template v-else>{{ meal_plan.title }}</template>
</a></h6>
<b-card-text style="text-overflow: ellipsis;">
<template v-if="recipe !== null">
<recipe-rating :recipe="recipe"></recipe-rating>
<template v-if="recipe.description !== null">
<span v-if="recipe.description.length > text_length">
{{ recipe.description.substr(0, text_length) + "\u2026" }}
</span>
<span v-if="recipe.description.length <= text_length">
{{ recipe.description }}
</span>
</template>
<p class="mt-1">
<last-cooked :recipe="recipe"></last-cooked>
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
</p>
<transition name="fade" mode="in-out">
<div class="row mt-3" v-if="detailed">
<div class="col-md-12">
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t('Ingredients') }}</h6>
<table class="table table-sm text-wrap">
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="s in recipe.steps">
<template v-for="i in s.ingredients">
<Ingredient-component :detailed="false" :ingredient="i" :ingredient_factor="1" :key="i.id"></Ingredient-component>
</template>
</template>
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
</table>
</div>
<b-card no-body v-hover v-if="recipe">
<a :href="clickUrl()">
<b-card-img-lazy style="height: 15vh; object-fit: cover" class="" :src="recipe_image" v-bind:alt="$t('Recipe_Image')" top></b-card-img-lazy>
<div class="card-img-overlay h-100 d-flex flex-column justify-content-right float-right text-right pt-2 pr-1">
<a>
<recipe-context-menu :recipe="recipe" class="float-right" v-if="recipe !== null"></recipe-context-menu>
</a>
</div>
</transition>
<div class="card-img-overlay w-50 d-flex flex-column justify-content-left float-left text-left pt-2" v-if="recipe.working_time !== 0 || recipe.waiting_time !== 0">
<b-badge pill variant="light" class="mt-1 font-weight-normal" v-if="recipe.working_time !== 0"><i class="fa fa-clock"></i> {{ recipe.working_time }} {{ $t("min") }} </b-badge>
<b-badge pill variant="secondary" class="mt-1 font-weight-normal" v-if="recipe.waiting_time !== 0"><i class="fa fa-pause"></i> {{ recipe.waiting_time }} {{ $t("min") }} </b-badge>
</div>
</a>
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t('External') }}</b-badge>
<!-- <b-badge pill variant="success"
v-if="Date.parse(recipe.created_at) > new Date(Date.now() - (7 * (1000 * 60 * 60 * 24)))">
{{ $t('New') }}
</b-badge> -->
<b-card-body class="p-4">
<h6>
<a :href="clickUrl()">
<template v-if="recipe !== null">{{ recipe.name }}</template>
<template v-else>{{ meal_plan.title }}</template>
</a>
</h6>
</template>
<template v-else>{{ meal_plan.note }}</template>
</b-card-text>
</b-card-body>
<b-card-text style="text-overflow: ellipsis">
<template v-if="recipe !== null">
<recipe-rating :recipe="recipe"></recipe-rating>
<template v-if="recipe.description !== null">
<span v-if="recipe.description.length > text_length">
{{ recipe.description.substr(0, text_length) + "\u2026" }}
</span>
<span v-if="recipe.description.length <= text_length">
{{ recipe.description }}
</span>
</template>
<p class="mt-1">
<last-cooked :recipe="recipe"></last-cooked>
<keywords-component :recipe="recipe" style="margin-top: 4px"></keywords-component>
</p>
<transition name="fade" mode="in-out">
<div class="row mt-3" v-if="show_detail">
<div class="col-md-12">
<h6 class="card-title"><i class="fas fa-pepper-hot"></i> {{ $t("Ingredients") }}</h6>
<ingredients-card :steps="recipe.steps" :header="false" :detailed="false" :servings="recipe.servings" />
</div>
</div>
</transition>
<b-card-footer v-if="footer_text !== undefined">
<i v-bind:class="footer_icon"></i> {{ footer_text }}
</b-card-footer>
</b-card>
<b-badge pill variant="info" v-if="!recipe.internal">{{ $t("External") }}</b-badge>
</template>
<template v-else>{{ meal_plan.note }}</template>
</b-card-text>
</b-card-body>
<b-card-footer v-if="footer_text !== undefined"> <i v-bind:class="footer_icon"></i> {{ footer_text }} </b-card-footer>
</b-card>
</template>
<script>
import RecipeContextMenu from "@/components/RecipeContextMenu";
import {resolveDjangoUrl, ResolveUrlMixin} from "@/utils/utils";
import RecipeRating from "@/components/RecipeRating";
import moment from "moment/moment";
import Vue from "vue";
import LastCooked from "@/components/LastCooked";
import KeywordsComponent from "@/components/KeywordsComponent";
import IngredientComponent from "@/components/IngredientComponent";
import RecipeContextMenu from "@/components/RecipeContextMenu"
import KeywordsComponent from "@/components/KeywordsComponent"
import { resolveDjangoUrl, ResolveUrlMixin } from "@/utils/utils"
import RecipeRating from "@/components/RecipeRating"
import moment from "moment/moment"
import Vue from "vue"
import LastCooked from "@/components/LastCooked"
import IngredientsCard from "@/components/IngredientsCard"
Vue.prototype.moment = moment
export default {
name: "RecipeCard",
mixins: [
ResolveUrlMixin,
],
components: {LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientComponent},
props: {
recipe: Object,
meal_plan: Object,
footer_text: String,
footer_icon: String
},
computed: {
detailed: function () {
return this.recipe.steps !== undefined;
name: "RecipeCard",
mixins: [ResolveUrlMixin],
components: { LastCooked, RecipeRating, KeywordsComponent, RecipeContextMenu, IngredientsCard },
props: {
recipe: Object,
meal_plan: Object,
footer_text: String,
footer_icon: String,
detailed: { type: Boolean, default: true },
},
text_length: function () {
if (this.detailed) {
return 200
} else {
return 120
}
mounted() {},
computed: {
show_detail: function () {
return this.recipe?.steps !== undefined && this.detailed
},
text_length: function () {
if (this.show_detail) {
return 200
} else {
return 120
}
},
recipe_image: function () {
if (this.recipe == null || this.recipe.image === null) {
return window.IMAGE_PLACEHOLDER
} else {
return this.recipe.image
}
},
},
methods: {
// TODO: convert this to genericAPI
clickUrl: function () {
if (this.recipe !== null) {
return resolveDjangoUrl("view_recipe", this.recipe.id)
} else {
return resolveDjangoUrl("view_plan_entry", this.meal_plan.id)
}
},
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener("mouseenter", () => {
el.classList.add("shadow")
})
el.addEventListener("mouseleave", () => {
el.classList.remove("shadow")
})
},
},
},
recipe_image: function () {
if (this.recipe == null || this.recipe.image === null) {
return window.IMAGE_PLACEHOLDER
} else {
return this.recipe.image
}
}
},
methods: {
// TODO: convert this to genericAPI
clickUrl: function () {
if (this.recipe !== null) {
return resolveDjangoUrl('view_recipe', this.recipe.id)
} else {
return resolveDjangoUrl('view_plan_entry', this.meal_plan.id)
}
}
},
directives: {
hover: {
inserted: function (el) {
el.addEventListener('mouseenter', () => {
el.classList.add("shadow")
});
el.addEventListener('mouseleave', () => {
el.classList.remove("shadow")
});
}
}
}
}
</script>
<style scoped>
.fade-enter-active, .fade-leave-active {
transition: opacity .5s;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
opacity: 0;
}
</style>

View File

@ -1,183 +1,175 @@
<template>
<div>
<div>
<div class="dropdown d-print-none">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v fa-lg"></i>
</a>
<div class="dropdown d-print-none">
<a class="btn shadow-none" href="javascript:void(0);" role="button" id="dropdownMenuLink"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fas fa-ellipsis-v fa-lg"></i>
</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i class="fas fa-pencil-alt fa-fw"></i> {{ $t("Edit") }}</a>
<div class="dropdown-menu dropdown-menu-right" aria-labelledby="dropdownMenuLink">
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i class="fas fa-exchange-alt fa-fw"></i> {{ $t("convert_internal") }}</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_recipe', recipe.id)"><i
class="fas fa-pencil-alt fa-fw"></i> {{ $t('Edit') }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)"><i class="fas fa-bookmark fa-fw"></i> {{ $t("Manage_Books") }}</button>
</a>
<a class="dropdown-item" :href="resolveDjangoUrl('edit_convert_recipe', recipe.id)" v-if="!recipe.internal"><i
class="fas fa-exchange-alt fa-fw"></i> {{ $t('convert_internal') }}</a>
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping')}?r=[${recipe.id},${servings_value}]`" v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t("Add_to_Shopping") }}
</a>
<a class="dropdown-item" v-if="recipe.internal" @click="addToShopping" href="#"> <i class="fas fa-shopping-cart fa-fw"></i> {{ $t("create_shopping_new") }} </a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_add_book_${modal_id}`)">
<i class="fas fa-bookmark fa-fw"></i> {{ $t('Manage_Books') }}
</button>
</a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i class="fas fa-calendar fa-fw"></i> {{ $t("Add_to_Plan") }} </a>
<a class="dropdown-item" :href="`${resolveDjangoUrl('view_shopping') }?r=[${recipe.id},${servings_value}]`"
v-if="recipe.internal" target="_blank" rel="noopener noreferrer">
<i class="fas fa-shopping-cart fa-fw"></i> {{ $t('Add_to_Shopping') }}
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i class="fas fa-clipboard-list fa-fw"></i> {{ $t("Log_Cooking") }}</button>
</a>
<a class="dropdown-item" @click="createMealPlan" href="javascript:void(0);"><i
class="fas fa-calendar fa-fw"></i> {{ $t('Add_to_Plan') }}
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" onclick="window.print()"><i class="fas fa-print fa-fw"></i> {{ $t("Print") }}</button>
</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="$bvModal.show(`id_modal_cook_log_${modal_id}`)"><i
class="fas fa-clipboard-list fa-fw"></i> {{ $t('Log_Cooking') }}
</button>
</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank" rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t("Export") }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" onclick="window.print()"><i
class="fas fa-print fa-fw"></i> {{ $t('Print') }}
</button>
</a>
<a class="dropdown-item" :href="resolveDjangoUrl('view_export') + '?r=' + recipe.id" target="_blank"
rel="noopener noreferrer"><i class="fas fa-file-export fa-fw"></i> {{ $t('Export') }}</a>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i
class="fas fa-share-alt fa-fw"></i> {{ $t('Share') }}
</button>
</a>
</div>
</div>
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id"></add-recipe-to-book>
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
<div class="row">
<div class="col col-md-12">
<label v-if="recipe_share_link !== undefined">{{ $t('Public share link') }}</label>
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link"/>
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary"
@click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t('Close') }}
</b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t('Copy') }}</b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t('Share') }} <i
class="fa fa-share-alt"></i></b-button>
<a href="javascript:void(0);">
<button class="dropdown-item" @click="createShareLink()" v-if="recipe.internal"><i class="fas fa-share-alt fa-fw"></i> {{ $t("Share") }}</button>
</a>
</div>
</div>
</div>
</b-modal>
<meal-plan-edit-modal :entry="entryEditing" :entryEditing_initial_recipe="[recipe]"
:entry-editing_initial_meal_type="[]" @save-entry="saveMealPlan"
:modal_id="`modal-meal-plan_${modal_id}`" :allow_delete="false" :modal_title="$t('Create_Meal_Plan_Entry')"></meal-plan-edit-modal>
</div>
<cook-log :recipe="recipe" :modal_id="modal_id"></cook-log>
<add-recipe-to-book :recipe="recipe" :modal_id="modal_id"></add-recipe-to-book>
<b-modal :id="`modal-share-link_${modal_id}`" v-bind:title="$t('Share')" hide-footer>
<div class="row">
<div class="col col-md-12">
<label v-if="recipe_share_link !== undefined">{{ $t("Public share link") }}</label>
<input ref="share_link_ref" class="form-control" v-model="recipe_share_link" />
<b-button class="mt-2 mb-3 d-none d-md-inline" variant="secondary" @click="$bvModal.hide(`modal-share-link_${modal_id}`)">{{ $t("Close") }} </b-button>
<b-button class="mt-2 mb-3 ml-md-2" variant="primary" @click="copyShareLink()">{{ $t("Copy") }}</b-button>
<b-button class="mt-2 mb-3 ml-2 float-right" variant="success" @click="shareIntend()">{{ $t("Share") }} <i class="fa fa-share-alt"></i></b-button>
</div>
</div>
</b-modal>
<meal-plan-edit-modal
:entry="entryEditing"
:entryEditing_initial_recipe="[recipe]"
:entryEditing_inital_servings="recipe.servings"
:entry-editing_initial_meal_type="[]"
@save-entry="saveMealPlan"
:modal_id="`modal-meal-plan_${modal_id}`"
:allow_delete="false"
:modal_title="$t('Create_Meal_Plan_Entry')"
></meal-plan-edit-modal>
<shopping-modal :recipe="recipe" :servings="servings_value" :modal_id="modal_id" />
</div>
</template>
<script>
import {makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts} from "@/utils/utils";
import CookLog from "@/components/CookLog";
import axios from "axios";
import AddRecipeToBook from "./AddRecipeToBook";
import MealPlanEditModal from "@/components/MealPlanEditModal";
import moment from "moment";
import Vue from "vue";
import {ApiApiFactory} from "@/utils/openapi/api";
import { makeToast, resolveDjangoUrl, ResolveUrlMixin, StandardToasts } from "@/utils/utils"
import CookLog from "@/components/CookLog"
import axios from "axios"
import AddRecipeToBook from "@/components/Modals/AddRecipeToBook"
import MealPlanEditModal from "@/components/MealPlanEditModal"
import ShoppingModal from "@/components/Modals/ShoppingModal"
import moment from "moment"
import Vue from "vue"
import { ApiApiFactory } from "@/utils/openapi/api"
Vue.prototype.moment = moment
export default {
name: 'RecipeContextMenu',
mixins: [
ResolveUrlMixin
],
components: {
AddRecipeToBook,
CookLog,
MealPlanEditModal
},
data() {
return {
servings_value: 0,
recipe_share_link: undefined,
modal_id: this.recipe.id + Math.round(Math.random() * 100000),
options: {
entryEditing: {
date: null,
id: -1,
meal_type: null,
note: "",
note_markdown: "",
recipe: null,
servings: 1,
shared: [],
title: '',
title_placeholder: this.$t('Title')
name: "RecipeContextMenu",
mixins: [ResolveUrlMixin],
components: {
AddRecipeToBook,
CookLog,
MealPlanEditModal,
ShoppingModal,
},
data() {
return {
servings_value: 0,
recipe_share_link: undefined,
modal_id: this.recipe.id + Math.round(Math.random() * 100000),
options: {
entryEditing: {
date: null,
id: -1,
meal_type: null,
note: "",
note_markdown: "",
recipe: null,
servings: 1,
shared: [],
title: "",
title_placeholder: this.$t("Title"),
},
},
entryEditing: {},
}
},
entryEditing: {},
}
},
props: {
recipe: Object,
servings: {
type: Number,
default: -1
}
},
mounted() {
this.servings_value = ((this.servings === -1) ? this.recipe.servings : this.servings)
},
methods: {
saveMealPlan: function (entry) {
entry.date = moment(entry.date).format("YYYY-MM-DD")
let apiClient = new ApiApiFactory()
apiClient.createMealPlan(entry).then(result => {
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch(error => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
createMealPlan(data) {
this.entryEditing = this.options.entryEditing
this.entryEditing.recipe = this.recipe
this.entryEditing.date = moment(new Date()).format('YYYY-MM-DD')
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
props: {
recipe: Object,
servings: {
type: Number,
default: -1,
},
},
createShareLink: function () {
axios.get(resolveDjangoUrl('api_share_link', this.recipe.id)).then(result => {
this.$bvModal.show(`modal-share-link_${this.modal_id}`)
this.recipe_share_link = result.data.link
}).catch(err => {
if (err.response.status === 403) {
makeToast(this.$t('Share'), this.$t('Sharing is not enabled for this space.'), 'danger')
}
})
mounted() {
this.servings_value = this.servings === -1 ? this.recipe.servings : this.servings
},
methods: {
saveMealPlan: function (entry) {
entry.date = moment(entry.date).format("YYYY-MM-DD")
let apiClient = new ApiApiFactory()
apiClient
.createMealPlan(entry)
.then((result) => {
this.$bvModal.hide(`modal-meal-plan_${this.modal_id}`)
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
})
.catch((error) => {
StandardToasts.makeStandardToast(StandardToasts.FAIL_CREATE)
})
},
createMealPlan(data) {
this.entryEditing = this.options.entryEditing
this.entryEditing.recipe = this.recipe
this.entryEditing.date = moment(new Date()).format("YYYY-MM-DD")
this.$bvModal.show(`modal-meal-plan_${this.modal_id}`)
},
createShareLink: function () {
axios
.get(resolveDjangoUrl("api_share_link", this.recipe.id))
.then((result) => {
this.$bvModal.show(`modal-share-link_${this.modal_id}`)
this.recipe_share_link = result.data.link
})
.catch((err) => {
if (err.response.status === 403) {
makeToast(this.$t("Share"), this.$t("Sharing is not enabled for this space."), "danger")
}
})
},
copyShareLink: function () {
let share_input = this.$refs.share_link_ref
share_input.select()
document.execCommand("copy")
},
shareIntend: function () {
let shareData = {
title: this.recipe.name,
text: `${this.$t("Check out this recipe: ")} ${this.recipe.name}`,
url: this.recipe_share_link,
}
navigator.share(shareData)
},
addToShopping() {
this.$bvModal.show(`shopping_${this.modal_id}`)
},
},
copyShareLink: function () {
let share_input = this.$refs.share_link_ref;
share_input.select();
document.execCommand("copy");
},
shareIntend: function () {
let shareData = {
title: this.recipe.name,
text: `${this.$t('Check out this recipe: ')} ${this.recipe.name}`,
url: this.recipe_share_link
}
navigator.share(shareData)
}
}
}
</script>

View File

@ -0,0 +1,299 @@
<template>
<div id="shopping_line_item">
<div class="col-12">
<b-container fluid>
<!-- summary rows -->
<b-row align-h="start">
<b-col cols="12" sm="2">
<div style="position: static" class="btn-group">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, entries)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
<input type="checkbox" class="text-right mx-3 mt-2" :checked="formatChecked" @change="updateChecked" :key="entries[0].id" />
</div>
</b-col>
<b-col cols="12" sm="10">
<b-row>
<b-col cols="6" sm="3">
<div v-if="Object.entries(formatAmount).length == 1">{{ Object.entries(formatAmount)[0][1] }} &ensp; {{ Object.entries(formatAmount)[0][0] }}</div>
<div class="small" v-else v-for="(x, i) in Object.entries(formatAmount)" :key="i">{{ x[1] }} &ensp; {{ x[0] }}</div>
</b-col>
<b-col cols="6" sm="7">
{{ formatFood }}
</b-col>
<b-col cols="6" sm="2" data-html2canvas-ignore="true">
<b-button size="sm" @click="showDetails = !showDetails" class="mr-2" variant="link">
<div class="text-nowrap">{{ showDetails ? "Hide" : "Show" }} Details</div>
</b-button>
</b-col>
</b-row>
</b-col>
</b-row>
<b-row align-h="center">
<b-col cols="12">
<div class="small text-muted text-truncate">{{ formatHint }}</div>
</b-col>
</b-row>
</b-container>
<!-- detail rows -->
<div class="card no-body" v-if="showDetails">
<b-container fluid>
<div v-for="e in entries" :key="e.id">
<b-row class="ml-2 small">
<b-col cols="6" 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"
style="text-overflow: ellipsis"
@click.stop="openRecipeCard($event, e)"
@mouseover="openRecipeCard($event, e)"
>
{{ formatOneRecipe(e) }}
</button>
</b-col>
<b-col cols="6" md="4" class="col-md-4 text-muted">{{ formatOneMealPlan(e) }}</b-col>
<b-col cols="12" md="4" class="col-md-4 text-muted text-right overflow-hidden text-nowrap">
{{ formatOneCreatedBy(e) }}
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
</b-col>
</b-row>
<b-row class="ml-2 light">
<b-col cols="12" sm="2">
<div style="position: static" class="btn-group">
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true">
<button
aria-haspopup="true"
aria-expanded="false"
type="button"
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 dropdown-toggle-no-caret"
@click.stop="$emit('open-context-menu', $event, e)"
>
<i class="fas fa-ellipsis-v fa-lg"></i>
</button>
</div>
<input type="checkbox" class="text-right mx-3 mt-2" :checked="e.checked" @change="updateChecked($event, e)" />
</div>
</b-col>
<b-col cols="12" sm="10">
<b-row>
<b-col cols="2" sm="2" md="1" class="text-nowrap">{{ formatOneAmount(e) }}</b-col>
<b-col cols="10" sm="4" md="2" class="text-nowrap">{{ formatOneUnit(e) }}</b-col>
<b-col cols="12" sm="6" md="4" class="text-nowrap">{{ formatOneFood(e) }}</b-col>
<b-col cols="12" sm="6" md="5">
<div class="small" v-for="(n, i) in formatOneNote(e)" :key="i">{{ n }}</div>
</b-col>
</b-row>
</b-col>
</b-row>
<hr class="w-75" />
</div>
</b-container>
</div>
<hr class="m-1" />
</div>
<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>
</div>
</template>
<script>
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"
Vue.use(BootstrapVue)
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 },
props: {
entries: {
type: Array,
},
groupby: { type: String },
},
data() {
return {
showDetails: false,
recipe: undefined,
servings: 1,
}
},
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
}
}
})
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
},
formatCategory: function () {
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
},
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(" ")
return mealplan_name
.map((x) => {
return this.formatOneMealPlan(x)
})
.join(" - ")
}
},
formatNotes: function () {
if (this.entries?.length == 1) {
return this.formatOneNote(this.entries[0]) || ""
}
return ""
},
},
watch: {},
mounted() {
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
},
methods: {
// this.genericAPI inherited from ApiMixin
formatDate: function (datetime) {
if (!datetime) {
return
}
return Intl.DateTimeFormat(window.navigator.language, { dateStyle: "short", timeStyle: "short" }).format(Date.parse(datetime))
},
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.username, "@", 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)
})
},
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 }
}
this.$emit("update-checkbox", update)
},
},
}
</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 */
/* } */
</style>

View File

@ -1,220 +1,190 @@
<template>
<div>
<hr />
<div>
<hr />
<template v-if="step.type === 'TEXT' || step.type === 'RECIPE'">
<div class="row" v-if="recipe.steps.length > 1">
<div class="col col-md-8">
<h5 class="text-primary">
<template v-if="step.name">{{ step.name }}</template>
<template v-else>{{ $t('Step') }} {{ index + 1 }}</template>
<small style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fas fa-user-clock"></i>
{{ step.time }} {{ $t('min') }}
</small>
<small v-if="start_time !== ''" class="d-print-none">
<b-link :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#">
{{ moment(start_time).add(step.time_offset, 'minutes').format('HH:mm') }}
</b-link>
</small>
</h5>
</div>
<div class="col col-md-4" style="text-align: right">
<b-button @click="details_visible = !details_visible" style="border: none; background: none"
class="shadow-none d-print-none"
:class="{ 'text-primary': details_visible, 'text-success': !details_visible}">
<i class="far fa-check-circle"></i>
</b-button>
</div>
</div>
</template>
<template v-if="step.type === 'TEXT'">
<b-collapse id="collapse-1" v-model="details_visible">
<div class="row">
<div class="col col-md-4"
v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
<table class="table table-sm">
<!-- eslint-disable vue/no-v-for-template-key-on-child -->
<template v-for="i in step.ingredients">
<Ingredient-component v-bind:ingredient="i" :ingredient_factor="ingredient_factor" :key="i.id"
@checked-state-changed="$emit('checked-state-changed', i)"></Ingredient-component>
</template>
<!-- eslint-enable vue/no-v-for-template-key-on-child -->
</table>
</div>
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1,}">
<compile-component :code="step.ingredients_markdown"
:ingredient_factor="ingredient_factor"></compile-component>
</div>
</div>
</b-collapse>
</template>
<template v-if="step.type === 'TIME' || step.type === 'FILE'">
<div class="row">
<div class="col-md-8 offset-md-2" style="text-align: center">
<h4 class="text-primary">
<template v-if="step.name">{{ step.name }}</template>
<template v-else>{{ $t('Step') }} {{ index + 1 }}</template>
</h4>
<span style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fa fa-stopwatch"></i>
{{ step.time }} {{ $t('min') }}</span>
<b-link class="d-print-none" :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#"
v-if="start_time !== ''">
{{ moment(start_time).add(step.time_offset, 'minutes').format('HH:mm') }}
</b-link>
</div>
<div class="col-md-2" style="text-align: right">
<b-button @click="details_visible = !details_visible" style="border: none; background: none"
class="shadow-none d-print-none"
:class="{ 'text-primary': details_visible, 'text-success': !details_visible}">
<i class="far fa-check-circle"></i>
</b-button>
</div>
</div>
<b-collapse id="collapse-1" v-model="details_visible">
<div class="row" v-if="step.instruction !== ''">
<div class="col col-md-12" style="text-align: center">
<compile-component :code="step.ingredients_markdown"
:ingredient_factor="ingredient_factor"></compile-component>
</div>
</div>
</b-collapse>
</template>
<div class="row" style="text-align: center">
<div class="col col-md-12">
<template v-if="step.file !== null">
<div
v-if="step.file.file.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
<img :src="step.file.file" style="max-width: 50vw; max-height: 50vh">
</div>
<div v-else>
<a :href="step.file.file" target="_blank" rel="noreferrer nofollow">{{ $t('Download') }} {{
$t('File')
}}</a>
</div>
<template v-if="step.type === 'TEXT' || step.type === 'RECIPE'">
<div class="row" v-if="recipe.steps.length > 1">
<div class="col col-md-8">
<h5 class="text-primary">
<template v-if="step.name">{{ step.name }}</template>
<template v-else>{{ $t("Step") }} {{ index + 1 }}</template>
<small style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fas fa-user-clock"></i> {{ step.time }} {{ $t("min") }} </small>
<small v-if="start_time !== ''" class="d-print-none">
<b-link :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#">
{{ moment(start_time).add(step.time_offset, "minutes").format("HH:mm") }}
</b-link>
</small>
</h5>
</div>
<div class="col col-md-4" style="text-align: right">
<b-button
@click="details_visible = !details_visible"
style="border: none; background: none"
class="shadow-none d-print-none"
:class="{ 'text-primary': details_visible, 'text-success': !details_visible }"
>
<i class="far fa-check-circle"></i>
</b-button>
</div>
</div>
</template>
</div>
</div>
<template v-if="step.type === 'TEXT'">
<b-collapse id="collapse-1" v-model="details_visible">
<div class="row">
<div class="col col-md-4" v-if="step.ingredients.length > 0 && (recipe.steps.length > 1 || force_ingredients)">
<table class="table table-sm">
<ingredients-card :steps="[step]" :ingredient_factor="ingredient_factor" @checked-state-changed="$emit('checked-state-changed', $event)" />
</table>
</div>
<div class="col" :class="{ 'col-md-8': recipe.steps.length > 1, 'col-md-12': recipe.steps.length <= 1 }">
<compile-component :code="step.ingredients_markdown" :ingredient_factor="ingredient_factor"></compile-component>
</div>
</div>
</b-collapse>
</template>
<div class="card" v-if="step.type === 'RECIPE' && step.step_recipe_data !== null">
<b-collapse id="collapse-1" v-model="details_visible">
<div class="card-body">
<h2 class="card-title">
<a :href="resolveDjangoUrl('view_recipe',step.step_recipe_data.id)">{{ step.step_recipe_data.name }}</a>
</h2>
<div v-for="(sub_step, index) in step.step_recipe_data.steps" v-bind:key="`substep_${sub_step.id}`">
<step-component :recipe="step.step_recipe_data" :step="sub_step" :ingredient_factor="ingredient_factor" :index="index"
:start_time="start_time" :force_ingredients="true"></step-component>
<template v-if="step.type === 'TIME' || step.type === 'FILE'">
<div class="row">
<div class="col-md-8 offset-md-2" style="text-align: center">
<h4 class="text-primary">
<template v-if="step.name">{{ step.name }}</template>
<template v-else>{{ $t("Step") }} {{ index + 1 }}</template>
</h4>
<span style="margin-left: 4px" class="text-muted" v-if="step.time !== 0"><i class="fa fa-stopwatch"></i> {{ step.time }} {{ $t("min") }}</span>
<b-link class="d-print-none" :id="`id_reactive_popover_${step.id}`" @click="openPopover" href="#" v-if="start_time !== ''">
{{ moment(start_time).add(step.time_offset, "minutes").format("HH:mm") }}
</b-link>
</div>
<div class="col-md-2" style="text-align: right">
<b-button
@click="details_visible = !details_visible"
style="border: none; background: none"
class="shadow-none d-print-none"
:class="{ 'text-primary': details_visible, 'text-success': !details_visible }"
>
<i class="far fa-check-circle"></i>
</b-button>
</div>
</div>
<b-collapse id="collapse-1" v-model="details_visible">
<div class="row" v-if="step.instruction !== ''">
<div class="col col-md-12" style="text-align: center">
<compile-component :code="step.ingredients_markdown" :ingredient_factor="ingredient_factor"></compile-component>
</div>
</div>
</b-collapse>
</template>
<div class="row" style="text-align: center">
<div class="col col-md-12">
<template v-if="step.file !== null">
<div v-if="step.file.file.includes('.png') || recipe.file_path.includes('.jpg') || recipe.file_path.includes('.jpeg') || recipe.file_path.includes('.gif')">
<img :src="step.file.file" style="max-width: 50vw; max-height: 50vh" />
</div>
<div v-else>
<a :href="step.file.file" target="_blank" rel="noreferrer nofollow">{{ $t("Download") }} {{ $t("File") }}</a>
</div>
</template>
</div>
</div>
</div>
</b-collapse>
</div>
<div v-if="start_time !== ''">
<b-popover
:target="`id_reactive_popover_${step.id}`"
triggers="click"
placement="bottom"
:ref="`id_reactive_popover_${step.id}`"
:title="$t('Step start time')">
<div>
<b-form-group
label="Time"
label-for="popover-input-1"
label-cols="3"
class="mb-1">
<b-form-input
type="datetime-local"
id="popover-input-1"
v-model.datetime-local="set_time_input"
size="sm"
></b-form-input>
</b-form-group>
<div class="card" v-if="step.type === 'RECIPE' && step.step_recipe_data !== null">
<b-collapse id="collapse-1" v-model="details_visible">
<div class="card-body">
<h2 class="card-title">
<a :href="resolveDjangoUrl('view_recipe', step.step_recipe_data.id)">{{ step.step_recipe_data.name }}</a>
</h2>
<div v-for="(sub_step, index) in step.step_recipe_data.steps" v-bind:key="`substep_${sub_step.id}`">
<step-component
:recipe="step.step_recipe_data"
:step="sub_step"
:ingredient_factor="ingredient_factor"
:index="index"
:start_time="start_time"
:force_ingredients="true"
></step-component>
</div>
</div>
</b-collapse>
</div>
<div class="row" style="margin-top: 1vh">
<div class="col-12" style="text-align: right">
<b-button @click="closePopover" size="sm" variant="secondary" style="margin-right:8px">Cancel</b-button>
<b-button @click="updateTime" size="sm" variant="primary">Ok</b-button>
</div>
</div>
</b-popover>
</div>
</div>
<div v-if="start_time !== ''">
<b-popover :target="`id_reactive_popover_${step.id}`" triggers="click" placement="bottom" :ref="`id_reactive_popover_${step.id}`" :title="$t('Step start time')">
<div>
<b-form-group label="Time" label-for="popover-input-1" label-cols="3" class="mb-1">
<b-form-input type="datetime-local" id="popover-input-1" v-model.datetime-local="set_time_input" size="sm"></b-form-input>
</b-form-group>
</div>
<div class="row" style="margin-top: 1vh">
<div class="col-12" style="text-align: right">
<b-button @click="closePopover" size="sm" variant="secondary" style="margin-right: 8px">{{ $t("Cancel") }}</b-button>
<b-button @click="updateTime" size="sm" variant="primary">{{ $t("Ok") }}</b-button>
</div>
</div>
</b-popover>
</div>
</div>
</template>
<script>
import { calculateAmount } from "@/utils/utils"
import {calculateAmount} from "@/utils/utils";
import { GettextMixin } from "@/utils/utils"
import {GettextMixin} from "@/utils/utils";
import CompileComponent from "@/components/CompileComponent";
import Vue from "vue";
import moment from "moment";
import {ResolveUrlMixin} from "@/utils/utils";
import IngredientComponent from "@/components/IngredientComponent";
import CompileComponent from "@/components/CompileComponent"
import IngredientsCard from "@/components/IngredientsCard"
import Vue from "vue"
import moment from "moment"
import { ResolveUrlMixin } from "@/utils/utils"
Vue.prototype.moment = moment
export default {
name: 'StepComponent',
mixins: [
GettextMixin,
ResolveUrlMixin,
],
components: {
IngredientComponent,
CompileComponent,
},
props: {
step: Object,
ingredient_factor: Number,
index: Number,
recipe: Object,
start_time: String,
force_ingredients: {
type: Boolean,
default: false
}
},
data() {
return {
details_visible: true,
set_time_input: '',
}
},
mounted() {
this.set_time_input = moment(this.start_time).add(this.step.time_offset, 'minutes').format('yyyy-MM-DDTHH:mm')
},
methods: {
calculateAmount: function (x) {
// used by the jinja2 template
return calculateAmount(x, this.ingredient_factor)
name: "StepComponent",
mixins: [GettextMixin, ResolveUrlMixin],
components: { CompileComponent, IngredientsCard },
props: {
step: Object,
ingredient_factor: Number,
index: Number,
recipe: Object,
start_time: String,
force_ingredients: {
type: Boolean,
default: false,
},
},
updateTime: function () {
let new_start_time = moment(this.set_time_input).add(this.step.time_offset * -1, 'minutes').format('yyyy-MM-DDTHH:mm')
data() {
return {
details_visible: true,
set_time_input: "",
}
},
mounted() {
this.set_time_input = moment(this.start_time).add(this.step.time_offset, "minutes").format("yyyy-MM-DDTHH:mm")
},
methods: {
calculateAmount: function (x) {
// used by the jinja2 template
return calculateAmount(x, this.ingredient_factor)
},
updateTime: function () {
let new_start_time = moment(this.set_time_input)
.add(this.step.time_offset * -1, "minutes")
.format("yyyy-MM-DDTHH:mm")
this.$emit('update-start-time', new_start_time)
this.closePopover()
this.$emit("update-start-time", new_start_time)
this.closePopover()
},
closePopover: function () {
this.$refs[`id_reactive_popover_${this.step.id}`].$emit("close")
},
openPopover: function () {
this.$refs[`id_reactive_popover_${this.step.id}`].$emit("open")
},
},
closePopover: function () {
this.$refs[`id_reactive_popover_${this.step.id}`].$emit('close')
},
openPopover: function () {
this.$refs[`id_reactive_popover_${this.step.id}`].$emit('open')
}
}
}
</script>

View File

@ -131,6 +131,7 @@
"Root": "Root",
"Ignore_Shopping": "Ignore Shopping",
"Shopping_Category": "Shopping Category",
"Shopping_Categories": "Shopping Categories",
"Edit_Food": "Edit Food",
"Move_Food": "Move Food",
"New_Food": "New Food",
@ -173,6 +174,15 @@
"Time": "Time",
"Text": "Text",
"Shopping_list": "Shopping List",
"Added_by": "Added By",
"Added_on": "Added On",
"AddToShopping": "Add to shopping list",
"IngredientInShopping": "This ingredient is in your shopping list.",
"NotInShopping": "{food} is not in your shopping list.",
"OnHand": "Currently On Hand",
"FoodOnHand": "You have {food} on hand.",
"FoodNotOnHand": "You do not have {food} on hand.",
"Undefined": "Undefined",
"Create_Meal_Plan_Entry": "Create meal plan entry",
"Edit_Meal_Plan_Entry": "Edit meal plan entry",
"Title": "Title",
@ -194,6 +204,11 @@
"Title_or_Recipe_Required": "Title or recipe selection required",
"Color": "Color",
"New_Meal_Type": "New Meal type",
"AddFoodToShopping": "Add {food} to your shopping list",
"RemoveFoodFromShopping": "Remove {food} from your shopping list",
"DeleteShoppingConfirm": "Are you sure that you want to remove all {food} from the shopping list?",
"IgnoredFood": "{food} is set to ignore shopping.",
"Add_Servings_to_Shopping": "Add {servings} Servings to Shopping",
"Week_Numbers": "Week numbers",
"Show_Week_Numbers": "Show week numbers ?",
"Export_As_ICal": "Export current period to iCal format",
@ -206,6 +221,35 @@
"Current_Period": "Current Period",
"Next_Day": "Next Day",
"Previous_Day": "Previous Day",
"Inherit": "Inherit",
"InheritFields": "Inherit Fields Values",
"FoodInherit": "Food Inheritable Fields",
"ShowUncategorizedFood": "Show Undefined",
"GroupBy": "Group By",
"SupermarketCategoriesOnly": "Supermarket Categories Only",
"MoveCategory": "Move To: ",
"CountMore": "...+{count} more",
"IgnoreThis": "Never auto-add {food} to shopping",
"DelayFor": "Delay for {hours} hours",
"Warning": "Warning",
"NoCategory": "No category selected.",
"InheritWarning": "{food} is set to inherit, changes may not persist.",
"ShowDelayed": "Show Delayed Items",
"Completed": "Completed",
"OfflineAlert": "You are offline, shopping list may not syncronize.",
"shopping_share": "Share Shopping List",
"shopping_auto_sync": "Autosync",
"mealplan_autoadd_shopping": "Auto Add Meal Plan",
"mealplan_autoexclude_onhand": "Exclude Food On Hand",
"mealplan_autoinclude_related": "Add Related Recipes",
"default_delay": "Default Delay Hours",
"shopping_share_desc": "Users will see all items you add to your shopping list. They must add you to see items on their list.",
"shopping_auto_sync_desc": "Setting to 0 will disable auto sync. When viewing a shopping list the list is updated every set seconds to sync changes someone else might have made. Useful when shopping with multiple people but will use mobile data.",
"mealplan_autoadd_shopping_desc": "Automatically add meal plan ingredients to shopping list.",
"mealplan_autoexclude_onhand_desc": "When adding a meal plan to the shopping list (manually or automatically), exclude ingredients that are currently on hand.",
"mealplan_autoinclude_related_desc": "When adding a meal plan to the shopping list (manually or automatically), include all related recipes.",
"default_delay_desc": "Default number of hours to delay a shopping list entry.",
"filter_to_supermarket": "Filter to Supermarket",
"Coming_Soon": "Coming-Soon",
"Auto_Planner": "Auto-Planner",
"New_Cookbook": "New cookbook",
@ -214,5 +258,23 @@
"err_move_self": "Cannot move item to itself",
"nothing": "Nothing to do",
"err_merge_self": "Cannot merge item with itself",
"show_sql": "Show SQL"
"show_sql": "Show SQL",
"filter_to_supermarket_desc": "By default, filter shopping list to only include categories for selected supermarket.",
"CategoryName": "Category Name",
"SupermarketName": "Supermarket Name",
"CategoryInstruction": "Drag categories to change the order categories appear in shopping list.",
"shopping_recent_days_desc": "Days of recent shopping list entries to display.",
"shopping_recent_days": "Recent Days",
"create_shopping_new": "Add to NEW Shopping List",
"download_pdf": "Download PDF",
"download_csv": "Download CSV",
"csv_delim_help": "Delimiter to use for CSV exports.",
"csv_delim_label": "CSV Delimiter",
"SuccessClipboard": "Shopping list copied to clipboard",
"copy_to_clipboard": "Copy to Clipboard",
"csv_prefix_help": "Prefix to add when copying list to the clipboard.",
"csv_prefix_label": "List Prefix",
"copy_markdown_table": "Copy as Markdown Table",
"in_shopping": "In Shopping List",
"DelayUntil": "Delay Until"
}

View File

@ -1,6 +1,7 @@
import axios from "axios";
import {djangoGettext as _, makeToast} from "@/utils/utils";
import {resolveDjangoUrl} from "@/utils/utils";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
@ -47,4 +48,8 @@ function handleError(error, message) {
makeToast('Error', message, 'danger')
console.log(error)
}
}
}
/*
* Generic class to use OpenAPIs with parameters and provide generic modals
* */

16
vue/src/utils/apiv2.js Normal file
View File

@ -0,0 +1,16 @@
/*
* Utility functions to use OpenAPIs generically
* */
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import axios from "axios";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
export class GenericAPI {
constructor(model, action) {
this.model = model;
this.action = action;
this.function_name = action + model
}
}

View File

@ -65,14 +65,19 @@ export class Models {
paginated: true,
move: true,
merge: true,
shop: true,
onhand: true,
badges: {
linked_recipe: true,
food_onhand: true,
shopping: true,
},
tags: [{ field: "supermarket_category", label: "name", color: "info" }],
// REQUIRED: unordered array of fields that can be set during create
create: {
// if not defined partialUpdate will use the same parameters, prepending 'id'
params: [["name", "description", "recipe", "ignore_shopping", "supermarket_category"]],
params: [["name", "description", "recipe", "food_onhand", "supermarket_category", "inherit", "inherit_fields"]],
form: {
name: {
form_field: true,
@ -98,8 +103,8 @@ export class Models {
shopping: {
form_field: true,
type: "checkbox",
field: "ignore_shopping",
label: i18n.t("Ignore_Shopping"),
field: "food_onhand",
label: i18n.t("OnHand"),
},
shopping_category: {
form_field: true,
@ -109,8 +114,30 @@ export class Models {
label: i18n.t("Shopping_Category"),
allow_create: true,
},
inherit_fields: {
form_field: true,
type: "lookup",
multiple: true,
field: "inherit_fields",
list: "FOOD_INHERIT_FIELDS",
label: i18n.t("InheritFields"),
condition: { field: "parent", value: true, condition: "exists" },
},
full_name: {
form_field: true,
type: "smalltext",
field: "full_name",
},
form_function: "FoodCreateDefault",
},
},
shopping: {
params: ["id", ["id", "amount", "unit", "_delete"]],
},
}
static FOOD_INHERIT_FIELDS = {
name: i18n.t("FoodInherit"),
apiName: "FoodInheritField",
}
static KEYWORD = {
@ -147,6 +174,11 @@ export class Models {
field: "icon",
label: i18n.t("Icon"),
},
full_name: {
form_field: true,
type: "smalltext",
field: "full_name",
},
},
},
}
@ -180,6 +212,30 @@ export class Models {
static SHOPPING_LIST = {
name: i18n.t("Shopping_list"),
apiName: "ShoppingListEntry",
list: {
params: ["id", "checked", "supermarket", "options"],
},
create: {
params: [["amount", "unit", "food", "checked"]],
form: {
unit: {
form_field: true,
type: "lookup",
field: "unit",
list: "UNIT",
label: i18n.t("Unit"),
allow_create: true,
},
food: {
form_field: true,
type: "lookup",
field: "food",
list: "FOOD",
label: i18n.t("Food"),
allow_create: true,
},
},
},
}
static RECIPE_BOOK = {
@ -370,41 +426,15 @@ export class Models {
name: i18n.t("Recipe"),
apiName: "Recipe",
list: {
params: [
"query",
"keywords",
"foods",
"units",
"rating",
"books",
"steps",
"keywordsOr",
"foodsOr",
"booksOr",
"internal",
"random",
"_new",
"page",
"pageSize",
"options",
],
config: {
foods: { type: "string" },
keywords: { type: "string" },
books: { type: "string" },
},
params: ["query", "keywords", "foods", "units", "rating", "books", "keywordsOr", "foodsOr", "booksOr", "internal", "random", "_new", "page", "pageSize", "options"],
// 'config': {
// 'foods': {'type': 'string'},
// 'keywords': {'type': 'string'},
// 'books': {'type': 'string'},
// }
},
}
static STEP = {
name: i18n.t("Step"),
apiName: "Step",
paginated: true,
list: {
header_component: {
name: "BetaWarning",
},
params: ["query", "page", "pageSize", "options"],
shopping: {
params: ["id", ["id", "list_recipe", "ingredients", "servings"]],
},
}
@ -461,6 +491,19 @@ export class Models {
},
},
}
static USER = {
name: i18n.t("User"),
apiName: "User",
paginated: false,
}
static STEP = {
name: i18n.t("Step"),
apiName: "Step",
list: {
params: ["recipe", "query", "page", "pageSize", "options"],
},
}
}
export class Actions {
@ -639,4 +682,7 @@ export class Actions {
},
},
}
static SHOPPING = {
function: "shopping",
}
}

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,7 @@ import Vue from "vue"
import { Actions, Models } from "./models"
export const ToastMixin = {
name: "ToastMixin",
methods: {
makeToast: function (title, message, variant = null) {
return makeToast(title, message, variant)
@ -147,12 +148,17 @@ export function resolveDjangoUrl(url, params = null) {
/*
* other utilities
* */
export function getUserPreference(pref) {
if (window.USER_PREF === undefined) {
export function getUserPreference(pref = undefined) {
let user_preference
if (document.getElementById("user_preference")) {
user_preference = JSON.parse(document.getElementById("user_preference").textContent)
} else {
return undefined
}
return window.USER_PREF[pref]
if (pref) {
return user_preference[pref]
}
return user_preference
}
export function calculateAmount(amount, factor) {
@ -358,6 +364,10 @@ export function getForm(model, action, item1, item2) {
if (f === "partialUpdate" && Object.keys(config).length == 0) {
config = { ...Actions.CREATE?.form, ...model.model_type?.["create"]?.form, ...model?.["create"]?.form }
config["title"] = { ...action?.form_title, ...model.model_type?.[f]?.form_title, ...model?.[f]?.form_title }
// form functions should not be inherited
if (config?.["form_function"]?.includes("Create")) {
delete config["form_function"]
}
}
let form = { fields: [] }
let value = ""
@ -525,3 +535,10 @@ const specialCases = {
})
},
}
export const formFunctions = {
FoodCreateDefault: function (form) {
form.fields.filter((x) => x.field === "inherit_fields")[0].value = getUserPreference("food_inherit_default")
return form
},
}