mirror of
https://github.com/TandoorRecipes/recipes.git
synced 2026-01-02 04:39:54 -05:00
Merge branch 'develop' into add-human-friendly-css-classes
This commit is contained in:
@@ -11,14 +11,14 @@
|
||||
<div class="flex-column" v-if="show_button_1">
|
||||
<slot name="button_1">
|
||||
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_search' }" v-bind:href="resolveDjangoUrl('view_search')">
|
||||
<i class="fas fa-fw fa-book " style="font-size: 1.5em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
|
||||
<i class="fas fa-fw fa-book " style="font-size: 1.4em"></i><br/><small>{{ $t('Recipes') }}</small></a> <!-- TODO localize -->
|
||||
</slot>
|
||||
|
||||
</div>
|
||||
<div class="flex-column" v-if="show_button_2">
|
||||
<slot name="button_2">
|
||||
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_plan' }" v-bind:href="resolveDjangoUrl('view_plan')">
|
||||
<i class="fas fa-calendar-alt" style="font-size: 1.5em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
|
||||
<i class="fas fa-calendar-alt" style="font-size: 1.4em"></i><br/><small>{{ $t('Meal_Plan') }}</small></a>
|
||||
</slot>
|
||||
|
||||
</div>
|
||||
@@ -53,14 +53,14 @@
|
||||
<div class="flex-column" v-if="show_button_3">
|
||||
<slot name="button_3">
|
||||
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_shopping' }" v-bind:href="resolveDjangoUrl('view_shopping')">
|
||||
<i class="fas fa-shopping-cart" style="font-size: 1.5em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
|
||||
<i class="fas fa-shopping-cart" style="font-size: 1.4em"></i><br/><small>{{ $t('Shopping_list') }}</small></a>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex-column">
|
||||
|
||||
<slot name="button_4" v-if="show_button_4">
|
||||
<a class="nav-link bottom-nav-link p-0" v-bind:class="{'bottom-nav-link-active': activeView === 'view_books' }" v-bind:href="resolveDjangoUrl('view_books')">
|
||||
<i class="fas fa-book-open" style="font-size: 1.5em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
|
||||
<i class="fas fa-book-open" style="font-size: 1.4em"></i><br/><small>{{ $t('Books') }}</small></a> <!-- TODO localize -->
|
||||
</slot>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import html2pdf from "html2pdf.js"
|
||||
|
||||
export default {
|
||||
name: "DownloadPDF",
|
||||
@@ -20,12 +19,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
downloadFile() {
|
||||
const doc = document.querySelector(this.dom)
|
||||
var options = {
|
||||
margin: 1,
|
||||
filename: this.name,
|
||||
}
|
||||
html2pdf().from(doc).set(options).save()
|
||||
window.print()
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,44 +1,60 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>EDITOR</h1>
|
||||
<div id="editor" style="" class="bg-info">
|
||||
|
||||
<b-button @click="toolbarTest()">Heading</b-button>
|
||||
<b-button @click="bold()">B</b-button>
|
||||
<div id="editor" style="background-color: #fff; border: solid 1px">
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {EditorState} from "@codemirror/state"
|
||||
import {keymap, EditorView, MatchDecorator, Decoration, WidgetType, ViewPlugin} from "@codemirror/view"
|
||||
import {defaultKeymap} from "@codemirror/commands"
|
||||
import {EditorSelection, EditorState} from "@codemirror/state"
|
||||
import {keymap, EditorView, MatchDecorator, Decoration, WidgetType, ViewPlugin, DecorationSet} from "@codemirror/view"
|
||||
import {defaultKeymap, history} from "@codemirror/commands"
|
||||
|
||||
import {markdown} from "@codemirror/lang-markdown"
|
||||
import {markdown, markdownLanguage} from "@codemirror/lang-markdown"
|
||||
import {autocompletion} from "@codemirror/autocomplete"
|
||||
|
||||
class PlaceholderWidget extends WidgetType { //TODO this is not working for some javascript magic reason
|
||||
import {defaultHighlightStyle, syntaxHighlighting, syntaxTree} from "@codemirror/language";
|
||||
|
||||
class TemplatePreviewWidget extends WidgetType {
|
||||
name = undefined
|
||||
constructor(name) {
|
||||
console.log(name)
|
||||
ingredients = []
|
||||
|
||||
constructor(name, ingredients) {
|
||||
super()
|
||||
this.name = name
|
||||
this.ingredients = ingredients
|
||||
}
|
||||
|
||||
eq(other) {
|
||||
return this.name == other.name
|
||||
getIngredientLabel(ingredient) {
|
||||
// TODO all possible null combinations
|
||||
return `${ingredient.amount} ${ingredient.unit.name} ${ingredient.food.name}`
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
let elt = document.createElement("span")
|
||||
elt.style.cssText = `
|
||||
border: 1px solid blue;
|
||||
border-radius: 4px;
|
||||
padding: 0 3px;
|
||||
background: lightblue;`
|
||||
let preview_span = document.createElement("span")
|
||||
|
||||
elt.textContent = "Food"
|
||||
return elt
|
||||
let display_text = 'ERROR'
|
||||
if (this.name.includes('ingredients')) {
|
||||
let ingredient_index = this.name.replace('{{ ingredients[', '').replace('] }}', '') // TODO support calculations ingredients[0]*0.5
|
||||
display_text = this.getIngredientLabel(this.ingredients[ingredient_index])
|
||||
}
|
||||
|
||||
if (this.name.includes('scale(')) {
|
||||
display_text = this.name.replace('{{ scale(', '').replace(') }}', '') // TODO support calculations scale(100)*2
|
||||
}
|
||||
|
||||
preview_span.innerHTML = display_text
|
||||
preview_span.style.cssText = ` border: 1px solid blue; border-radius: 4px; padding: 0 3px; background: lightblue;`
|
||||
return preview_span
|
||||
}
|
||||
|
||||
ignoreEvent() {
|
||||
@@ -49,39 +65,69 @@ class PlaceholderWidget extends WidgetType { //TODO this is not working for some
|
||||
export default {
|
||||
name: "MarkdownEditorComponent",
|
||||
props: {},
|
||||
computed: {},
|
||||
computed: {
|
||||
autocomplete_options() {
|
||||
let autocomplete_options = []
|
||||
|
||||
let index = 0
|
||||
for (let i of this.ingredients) {
|
||||
autocomplete_options.push({label: i.food.name, type: "text", apply: `{{ ingredients[${index}] }}`, detail: `${i.amount} ${i.unit.name} ${i.food.name}`})
|
||||
index++
|
||||
}
|
||||
|
||||
autocomplete_options.push({label: "Scale", type: "text", apply: "{{ scale(100) }}", detail: "simple scalable number"})
|
||||
return autocomplete_options
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
editor_view: null,
|
||||
ingredients: [
|
||||
{amount: 20, food: {'name': 'raspberry'}, unit: {'name': 'pcs'}},
|
||||
{amount: 100, food: {'name': 'sugar'}, unit: {'name': 'g'}},
|
||||
{amount: 250, food: {'name': 'water'}, unit: {'name': 'ml'}},
|
||||
{amount: 1, food: {'name': 'salt'}, unit: {'name': 'pinch'}},
|
||||
]
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
const placeholderMatcher = new MatchDecorator({
|
||||
regexp: /\{\{\singredients\[\d\]\s\}\}/g,
|
||||
decoration: match => Decoration.replace({
|
||||
widget: new PlaceholderWidget(match[0]),
|
||||
})
|
||||
const decoMatcher = new MatchDecorator({
|
||||
regexp: /\{\{ (?:scale\(\d+\)|ingredients\[\d+\]) \}\}/g,
|
||||
decorate: (add, from, to, match, view) => {
|
||||
const templatePreview = new TemplatePreviewWidget(match[0], this.ingredients);
|
||||
add(to, to, Decoration.widget({widget: templatePreview, side: 1}));
|
||||
},
|
||||
})
|
||||
|
||||
const placeholders = ViewPlugin.fromClass(class {
|
||||
placeholders
|
||||
decorations
|
||||
|
||||
constructor(view) {
|
||||
this.placeholders = placeholderMatcher.createDeco(view)
|
||||
this.decorations = decoMatcher.createDeco(view)
|
||||
}
|
||||
|
||||
update(update) {
|
||||
this.placeholders = placeholderMatcher.updateDeco(update, this.placeholders)
|
||||
update(viewUpdate) {
|
||||
this.decorations = decoMatcher.updateDeco(viewUpdate, this.decorations)
|
||||
}
|
||||
}, {
|
||||
decorations: instance => instance.placeholders,
|
||||
provide: plugin => EditorView.atomicRanges.of(view => {
|
||||
return view.plugin(plugin)?.placeholders || Decoration.none
|
||||
})
|
||||
decorations: instance => instance.decorations,
|
||||
|
||||
})
|
||||
|
||||
let startState = EditorState.create({
|
||||
doc: "Das ist eine Beschreibung \nPacke {{ ingredients[1] }} in das Fass mit {{ ingredients[3] }}\nTest Bla Bla",
|
||||
extensions: [keymap.of(defaultKeymap), placeholders, markdown(), autocompletion({override: [this.foodTemplateAutoComplete]})]
|
||||
doc: "## Header\n\nDas ist eine Beschreibung [test](https://google.com) \nPacke {{ ingredients[1] }} in das Fass mit {{ ingredients[3] }}\nTest Bla Bla {{ scale(100) }} \n\n- test\n- test 2\n- test3",
|
||||
extensions: [
|
||||
history(),
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
keymap.of(defaultKeymap),
|
||||
placeholders,
|
||||
markdown({base: markdownLanguage, highlightFormatting: true}),
|
||||
autocompletion({override: [this.foodTemplateAutoComplete]})
|
||||
]
|
||||
})
|
||||
|
||||
let view = new EditorView({
|
||||
this.editor_view = new EditorView({
|
||||
state: startState,
|
||||
extensions: [],
|
||||
parent: document.getElementById("editor")
|
||||
@@ -90,16 +136,74 @@ export default {
|
||||
methods: {
|
||||
foodTemplateAutoComplete: function (context) {
|
||||
let word = context.matchBefore(/\w*/)
|
||||
if (word.from == word.to && !context.explicit)
|
||||
if (word.from === word.to && !context.explicit)
|
||||
return null
|
||||
return {
|
||||
from: word.from,
|
||||
options: [
|
||||
{label: "Mehl", type: "text", apply: "{{ ingredients[1] }}", detail: "template"},
|
||||
{label: "Butter", type: "text", apply: "{{ ingredients[2] }}", detail: "template"},
|
||||
{label: "Salz", type: "text", apply: "{{ ingredients[3] }}", detail: "template"},
|
||||
]
|
||||
options: this.autocomplete_options
|
||||
}
|
||||
},
|
||||
toolbarTest() {
|
||||
const transaction = this.editor_view.state.changeByRange((range) => {
|
||||
const prefix = '###'
|
||||
const docText = this.editor_view.state.doc.toString()
|
||||
let text = this.editor_view.state.sliceDoc(range.from, range.to)
|
||||
|
||||
let rangeFrom = range.from
|
||||
|
||||
while (rangeFrom > 0) {
|
||||
if (docText[rangeFrom - 1] === "\n") {
|
||||
break
|
||||
}
|
||||
rangeFrom -= 1
|
||||
}
|
||||
|
||||
text = this.editor_view.state.sliceDoc(rangeFrom, range.to)
|
||||
const textBefore = `\n${text}`
|
||||
const newText = textBefore.replace(/\n/g, `\n${prefix}`)
|
||||
|
||||
const changes = {
|
||||
from: rangeFrom,
|
||||
to: range.to,
|
||||
insert: newText,
|
||||
anchor: rangeFrom + prefix.length + 1,
|
||||
textBefore: textBefore
|
||||
}
|
||||
|
||||
return {
|
||||
changes,
|
||||
range: EditorSelection.range(changes.anchor, changes.anchor)
|
||||
}
|
||||
})
|
||||
|
||||
this.editor_view.dispatch(transaction)
|
||||
},
|
||||
bold() {
|
||||
const transaction = this.editor_view.state.changeByRange((range) => {
|
||||
|
||||
if (range.anchor === range.head) {
|
||||
console.log('nothing selected --> nothing bold')
|
||||
} else {
|
||||
let selected_text = this.editor_view.state.sliceDoc(range.from, range.to)
|
||||
|
||||
let new_text = `**${selected_text}**`
|
||||
|
||||
const changes = {
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
insert: new_text,
|
||||
}
|
||||
|
||||
return {changes, range: EditorSelection.range(range.anchor + 2, range.head + 2)}
|
||||
}
|
||||
|
||||
|
||||
})
|
||||
|
||||
this.editor_view.dispatch(transaction)
|
||||
},
|
||||
heading(editor, heading_size) {
|
||||
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -186,8 +186,5 @@ export default {
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.b-form-spinbutton.form-control {
|
||||
background-color: #e9ecef;
|
||||
border: 1px solid #ced4da;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
74
vue/src/components/NumberScalerComponent.vue
Normal file
74
vue/src/components/NumberScalerComponent.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
<b-button-group class="w-100 mt-1">
|
||||
<b-button @click="updateNumber( 'half')" variant="outline-info"
|
||||
:disabled="disable"><i class="fas fa-divide"></i> 2
|
||||
</b-button>
|
||||
<b-button variant="outline-info" @click="updateNumber( 'sub')"
|
||||
:disabled="disable"><i class="fas fa-minus"></i>
|
||||
</b-button>
|
||||
<b-button variant="outline-info" @click="updateNumber('prompt')"
|
||||
:disabled="disable">
|
||||
{{ number }}
|
||||
</b-button>
|
||||
<b-button variant="outline-info" @click="updateNumber( 'add')"
|
||||
:disabled="disable"><i class="fas fa-plus"></i>
|
||||
</b-button>
|
||||
<b-button @click="updateNumber('multiply')" variant="outline-info"
|
||||
:disabled="disable"><i class="fas fa-times"></i> 2
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
|
||||
export default {
|
||||
name: "NumberScalerComponent",
|
||||
props: {
|
||||
number: {type: Number, default:0},
|
||||
disable: {type: Boolean, default: false}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* perform given operation on linked number
|
||||
* @param operation update mode
|
||||
*/
|
||||
updateNumber: function(operation) {
|
||||
if (operation === 'half') {
|
||||
this.$emit('change', this.number / 2)
|
||||
}
|
||||
if (operation === 'multiply') {
|
||||
this.$emit('change', this.number * 2)
|
||||
}
|
||||
if (operation === 'add') {
|
||||
this.$emit('change', this.number + 1)
|
||||
}
|
||||
if (operation === 'sub') {
|
||||
this.$emit('change', this.number - 1)
|
||||
}
|
||||
if (operation === 'prompt') {
|
||||
let input_number = prompt(this.$t('Input'), this.number);
|
||||
if (input_number !== null && input_number !== "" && !isNaN(input_number) && !isNaN(parseFloat(input_number))) {
|
||||
this.$emit('change', parseFloat(input_number))
|
||||
} else {
|
||||
console.log('Invalid number input in prompt', input_number)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div v-if="metadata !== undefined">
|
||||
{{ $t('Data_Import_Info') }}
|
||||
<a href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{$t('Learn_More')}}</a>
|
||||
<a href="https://github.com/TandoorRecipes/open-tandoor-data" target="_blank" rel="noreferrer nofollow">{{ $t('Learn_More') }}</a>
|
||||
|
||||
|
||||
<select class="form-control" v-model="selected_version">
|
||||
@@ -18,13 +18,17 @@
|
||||
<div v-if="selected_version !== undefined" class="mt-3">
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>{{ $t('Import') }}</th>
|
||||
<th>{{ $t('Datatype') }}</th>
|
||||
<th>{{ $t('Number of Objects') }}</th>
|
||||
<th>{{ $t('Imported') }}</th>
|
||||
</tr>
|
||||
<tr v-for="d in metadata.datatypes" v-bind:key="d">
|
||||
<td>{{ $t(d.charAt(0).toUpperCase() + d.slice(1)) }}</td>
|
||||
<td>{{ metadata[selected_version][d] }}</td>
|
||||
<tr v-for="d in datatypes" v-bind:key="d.name">
|
||||
<td>
|
||||
<b-checkbox v-model="d.selected"></b-checkbox>
|
||||
</td>
|
||||
<td>{{ $t(d.name.charAt(0).toUpperCase() + d.name.slice(1)) }}</td>
|
||||
<td>{{ metadata[selected_version][d.name] }}</td>
|
||||
<td>
|
||||
<template v-if="import_count !== undefined">{{ import_count[d] }}</template>
|
||||
</td>
|
||||
@@ -59,6 +63,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
metadata: undefined,
|
||||
datatypes: {},
|
||||
selected_version: undefined,
|
||||
update_existing: true,
|
||||
use_metric: true,
|
||||
@@ -70,6 +75,12 @@ export default {
|
||||
|
||||
axios.get(resolveDjangoUrl('api_import_open_data')).then(r => {
|
||||
this.metadata = r.data
|
||||
for (let i in this.metadata.datatypes) {
|
||||
this.datatypes[this.metadata.datatypes[i]] = {
|
||||
name: this.metadata.datatypes[i],
|
||||
selected: false,
|
||||
}
|
||||
}
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
@@ -78,7 +89,7 @@ export default {
|
||||
doImport: function () {
|
||||
axios.post(resolveDjangoUrl('api_import_open_data'), {
|
||||
'selected_version': this.selected_version,
|
||||
'selected_datatypes': this.metadata.datatypes,
|
||||
'selected_datatypes': this.datatypes,
|
||||
'update_existing': this.update_existing,
|
||||
'use_metric': this.use_metric,
|
||||
}).then(r => {
|
||||
|
||||
@@ -238,6 +238,7 @@ export default {
|
||||
},
|
||||
props: {
|
||||
recipe_id: Number,
|
||||
def_servings: Number,
|
||||
recipe_obj: {type: Object, default: null},
|
||||
show_context_menu: {type: Boolean, default: true},
|
||||
enable_keyword_links: {type: Boolean, default: true},
|
||||
@@ -321,8 +322,13 @@ export default {
|
||||
|
||||
|
||||
if (this.recipe.image === null) this.printReady()
|
||||
|
||||
this.servings = this.servings_cache[this.rootrecipe.id] = this.recipe.servings
|
||||
window.RECIPE_SERVINGS = Number(window.RECIPE_SERVINGS)
|
||||
if (window.RECIPE_SERVINGS && ! isNaN(window.RECIPE_SERVINGS)) {
|
||||
//I am not sure this is the best way. This overwrites our servings cache, which may not be intended?
|
||||
this.servings = window.RECIPE_SERVINGS
|
||||
} else {
|
||||
this.servings = this.servings_cache[this.rootrecipe.id] = this.recipe.servings
|
||||
}
|
||||
this.loading = false
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div v-if="user_preferences !== undefined">
|
||||
<div v-if="useUserPreferenceStore().user_settings !== undefined">
|
||||
<b-form-group :label="$t('shopping_share')" :description="$t('shopping_share_desc')">
|
||||
<generic-multiselect
|
||||
@change="user_preferences.shopping_share = $event.val; updateSettings(false)"
|
||||
@change="useUserPreferenceStore().user_settings.shopping_share = $event.val; updateSettings(false)"
|
||||
:model="Models.USER"
|
||||
:initial_selection="user_preferences.shopping_share"
|
||||
:initial_selection="useUserPreferenceStore().user_settings.shopping_share"
|
||||
label="display_name"
|
||||
:multiple="true"
|
||||
:placeholder="$t('User')"
|
||||
@@ -12,101 +12,98 @@
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('shopping_auto_sync')" :description="$t('shopping_auto_sync_desc')">
|
||||
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="user_preferences.shopping_auto_sync"
|
||||
@change="updateSettings(false)"></b-form-input>
|
||||
<b-form-input type="range" :min="SHOPPING_MIN_AUTOSYNC_INTERVAL" max="60" step="1" v-model="useUserPreferenceStore().user_settings.shopping_auto_sync"
|
||||
@change="updateSettings(false)" :disabled="useUserPreferenceStore().user_settings.shopping_auto_sync < 1"></b-form-input>
|
||||
<div class="text-center">
|
||||
<span v-if="user_preferences.shopping_auto_sync > 0">
|
||||
{{ Math.round(user_preferences.shopping_auto_sync) }}
|
||||
<span v-if="user_preferences.shopping_auto_sync === 1">{{ $t('Second') }}</span>
|
||||
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">
|
||||
{{ Math.round(useUserPreferenceStore().user_settings.shopping_auto_sync) }}
|
||||
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync === 1">{{ $t('Second') }}</span>
|
||||
<span v-else> {{ $t('Seconds') }}</span>
|
||||
</span>
|
||||
|
||||
<span v-if="user_preferences.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
|
||||
<span v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Disable') }}</span>
|
||||
</div>
|
||||
<br/>
|
||||
<b-button class="btn btn-sm" @click="user_preferences.shopping_auto_sync = 0; updateSettings(false)">{{ $t('Disabled') }}</b-button>
|
||||
<b-button class="btn btn-sm" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = 0; updateSettings(false)"
|
||||
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync > 0">{{ $t('Disable') }}</b-button>
|
||||
<b-button class="btn btn-sm btn-success" @click="useUserPreferenceStore().user_settings.shopping_auto_sync = SHOPPING_MIN_AUTOSYNC_INTERVAL; updateSettings(false)"
|
||||
v-if="useUserPreferenceStore().user_settings.shopping_auto_sync < 1">{{ $t('Enable') }}</b-button>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('mealplan_autoadd_shopping_desc')">
|
||||
<b-form-checkbox v-model="user_preferences.mealplan_autoadd_shopping"
|
||||
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoadd_shopping"
|
||||
@change="updateSettings(false)">{{ $t('mealplan_autoadd_shopping') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('mealplan_autoexclude_onhand_desc')">
|
||||
<b-form-checkbox v-model="user_preferences.mealplan_autoexclude_onhand"
|
||||
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoexclude_onhand"
|
||||
@change="updateSettings(false)">{{ $t('mealplan_autoexclude_onhand') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('mealplan_autoinclude_related_desc')">
|
||||
<b-form-checkbox v-model="user_preferences.mealplan_autoinclude_related"
|
||||
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.mealplan_autoinclude_related"
|
||||
@change="updateSettings(false)">{{ $t('mealplan_autoinclude_related') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('shopping_add_onhand_desc')">
|
||||
<b-form-checkbox v-model="user_preferences.shopping_add_onhand"
|
||||
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.shopping_add_onhand"
|
||||
@change="updateSettings(false)">{{ $t('shopping_add_onhand') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('default_delay')" :description="$t('default_delay_desc')">
|
||||
<b-form-input type="range" min="1" max="72" step="1" v-model="user_preferences.default_delay"
|
||||
<b-form-input type="range" min="1" max="72" step="1" v-model="useUserPreferenceStore().user_settings.default_delay"
|
||||
@change="updateSettings(false)"></b-form-input>
|
||||
<div class="text-center">
|
||||
<span>{{ Math.round(user_preferences.default_delay) }}
|
||||
<span v-if="user_preferences.default_delay === 1">{{ $t('Hour') }}</span>
|
||||
<span>{{ Math.round(useUserPreferenceStore().user_settings.default_delay) }}
|
||||
<span v-if="useUserPreferenceStore().user_settings.default_delay === 1">{{ $t('Hour') }}</span>
|
||||
<span v-else> {{ $t('Hours') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :description="$t('filter_to_supermarket_desc')">
|
||||
<b-form-checkbox v-model="user_preferences.filter_to_supermarket"
|
||||
<b-form-checkbox v-model="useUserPreferenceStore().user_settings.filter_to_supermarket"
|
||||
@change="updateSettings(false)">{{ $t('filter_to_supermarket') }}
|
||||
</b-form-checkbox>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('shopping_recent_days')" :description="$t('shopping_recent_days_desc')">
|
||||
<b-form-input type="range" min="0" max="14" step="1" v-model="user_preferences.shopping_recent_days"
|
||||
<b-form-input type="range" min="0" max="14" step="1" v-model="useUserPreferenceStore().user_settings.shopping_recent_days"
|
||||
@change="updateSettings(false)"></b-form-input>
|
||||
<div class="text-center">
|
||||
<span>{{ Math.round(user_preferences.shopping_recent_days) }}
|
||||
<span v-if="user_preferences.shopping_recent_days === 1">{{ $t('Day') }}</span>
|
||||
<span>{{ Math.round(useUserPreferenceStore().user_settings.shopping_recent_days) }}
|
||||
<span v-if="useUserPreferenceStore().user_settings.shopping_recent_days === 1">{{ $t('Day') }}</span>
|
||||
<span v-else> {{ $t('Days') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('csv_delim_label')" :description="$t('csv_delim_help')">
|
||||
<b-form-input v-model="user_preferences.csv_delim" @change="updateSettings(false)"></b-form-input>
|
||||
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_delim" @change="updateSettings(false)"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group :label="$t('csv_prefix_label')" :description="$t('csv_prefix_help')">
|
||||
<b-form-input v-model="user_preferences.csv_prefix" @change="updateSettings(false)"></b-form-input>
|
||||
<b-form-input v-model="useUserPreferenceStore().user_settings.csv_prefix" @change="updateSettings(false)"></b-form-input>
|
||||
</b-form-group>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import {ApiMixin, StandardToasts} from "@/utils/utils";
|
||||
|
||||
import axios from "axios";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect";
|
||||
|
||||
axios.defaults.xsrfCookieName = 'csrftoken'
|
||||
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
|
||||
export default {
|
||||
name: "ShoppingSettingsComponent",
|
||||
mixins: [ApiMixin],
|
||||
components: {GenericMultiselect},
|
||||
props: {
|
||||
user_id: Number,
|
||||
},
|
||||
props: { },
|
||||
data() {
|
||||
return {
|
||||
user_preferences: undefined,
|
||||
@@ -115,30 +112,15 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.user_preferences = this.preferences
|
||||
this.languages = window.AVAILABLE_LANGUAGES
|
||||
this.loadSettings()
|
||||
|
||||
useUserPreferenceStore().loadUserSettings(false)
|
||||
},
|
||||
methods: {
|
||||
loadSettings: function () {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
apiFactory.retrieveUserPreference(this.user_id.toString()).then(result => {
|
||||
this.user_preferences = result.data
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_FETCH, err)
|
||||
})
|
||||
},
|
||||
useUserPreferenceStore,
|
||||
updateSettings: function (reload) {
|
||||
let apiFactory = new ApiApiFactory()
|
||||
this.$emit('updated', this.user_preferences)
|
||||
apiFactory.partialUpdateUserPreference(this.user_id.toString(), this.user_preferences).then(result => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.SUCCESS_UPDATE)
|
||||
if (reload) {
|
||||
location.reload()
|
||||
}
|
||||
}).catch(err => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
useUserPreferenceStore().updateUserSettings()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,176 +1,134 @@
|
||||
<template>
|
||||
<div id="shopping_line_item" class="pt-1">
|
||||
<b-row align-h="start"
|
||||
ref="shopping_line_item" class="invis-border">
|
||||
<b-col cols="2" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
|
||||
v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0"
|
||||
variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
|
||||
:class="showDetails ? 'rotated' : ''"></i></div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
<b-col cols="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
|
||||
@click.stop="$emit('open-context-menu', $event, entries)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-0 pl-1 pr-md-3 pl-md-3 dropdown-toggle-no-caret">
|
||||
<i class="fas fa-ellipsis-v"></i>
|
||||
</button>
|
||||
<div class="swipe-container" :id="item_container_id" @touchend="handleSwipe()"
|
||||
v-if="(useUserPreferenceStore().device_settings.shopping_show_checked_entries || !is_checked) && (useUserPreferenceStore().device_settings.shopping_show_delayed_entries || !is_delayed)"
|
||||
>
|
||||
<div class="swipe-action" :class="{'bg-success': !is_checked , 'bg-warning': is_checked }">
|
||||
<i class="swipe-icon fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
|
||||
</div>
|
||||
|
||||
<b-button-group class="swipe-element">
|
||||
<b-button variant="primary" v-if="is_delayed">
|
||||
<i class="fa-fw fas fa-hourglass-half"></i>
|
||||
</b-button>
|
||||
<div class="card flex-grow-1 btn-block p-2" @click="detail_modal_visible = true">
|
||||
<div class="d-flex">
|
||||
<div class="d-flex flex-column pr-2" v-if="Object.keys(amounts).length> 0">
|
||||
<span v-for="a in amounts" v-bind:key="a.id">
|
||||
|
||||
<span><i class="fas fa-check" v-if="a.checked && !is_checked"></i><i class="fas fa-hourglass-half" v-if="a.delayed && !a.checked"></i> <b>{{ a.amount }} {{
|
||||
a.unit
|
||||
}} </b></span>
|
||||
<br/></span>
|
||||
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-grow-1 align-self-center">
|
||||
{{ food.name }} <br/>
|
||||
<span v-if="info_row"><small class="text-muted">{{ info_row }}</small></span>
|
||||
</div>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="px-1 justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control"
|
||||
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="8">
|
||||
<b-row class="d-flex h-100">
|
||||
<b-col cols="6" md="3" class="d-flex align-items-center" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
|
||||
v-if="Object.entries(formatAmount).length == 1">
|
||||
<strong class="mr-1">{{ Object.entries(formatAmount)[0][1] }}</strong>
|
||||
{{ Object.entries(formatAmount)[0][0] }}
|
||||
</b-col>
|
||||
<b-col cols="6" md="3" class="d-flex flex-column" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
|
||||
v-if="Object.entries(formatAmount).length != 1">
|
||||
<div class="small" v-for="(x, i) in Object.entries(formatAmount)" :key="i">
|
||||
{{ x[1] }}  
|
||||
{{ x[0] }}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<b-button variant="success" @click="useShoppingListStore().setEntriesCheckedState(entries, !is_checked, true)"
|
||||
:class="{'btn-success': !is_checked, 'btn-warning': is_checked}">
|
||||
<i class="d-print-none fa-fw fas" :class="{'fa-check': !is_checked , 'fa-cart-plus': is_checked }"></i>
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
<div class="swipe-action bg-primary justify-content-end">
|
||||
<i class="fa-fw fas fa-hourglass-half swipe-icon"></i>
|
||||
</div>
|
||||
|
||||
<b-modal v-model="detail_modal_visible" @hidden="detail_modal_visible = false" body-class="pr-4 pl-4 pt-0">
|
||||
<template #modal-title>
|
||||
<h5> {{ food_row }}</h5>
|
||||
<small class="text-muted">{{ food.description }}</small>
|
||||
|
||||
</template>
|
||||
|
||||
<template #default>
|
||||
<h5 class="mt-2">{{ $t('Quick actions') }}</h5>
|
||||
{{ $t('Category') }}
|
||||
<b-form-select
|
||||
class="form-control mb-2"
|
||||
:options="useShoppingListStore().supermarket_categories"
|
||||
text-field="name"
|
||||
value-field="id"
|
||||
v-model="food.supermarket_category"
|
||||
@change="detail_modal_visible = false; updateFoodCategory(food)"
|
||||
></b-form-select>
|
||||
|
||||
<b-button variant="info" block
|
||||
@click="detail_modal_visible = false;useShoppingListStore().delayEntries(entries,!is_delayed, true)">
|
||||
{{ $t('Postpone') }}
|
||||
</b-button>
|
||||
|
||||
|
||||
<h6 class="mt-2">{{ $t('Entries') }}</h6>
|
||||
|
||||
|
||||
<b-row v-for="e in entries" v-bind:key="e.id">
|
||||
<b-col cold="12">
|
||||
|
||||
<b-button-group class="w-100">
|
||||
<div class="card flex-grow-1 btn-block p-2">
|
||||
<span><i class="fas fa-check" v-if="e.checked"></i><i class="fas fa-hourglass-half" v-if="e.delay_until !== null && !e.checked"></i>
|
||||
<b><span v-if="e.amount > 0">{{ e.amount }}</span> {{ e.unit?.name }}</b> {{ food.name }}</span>
|
||||
<span><small class="text-muted">
|
||||
<span v-if="e.recipe_mealplan && e.recipe_mealplan.recipe_name !== ''">
|
||||
<a :href="resolveDjangoUrl('view_recipe', e.recipe_mealplan.recipe)"> <b> {{
|
||||
e.recipe_mealplan.recipe_name
|
||||
}} </b></a>({{
|
||||
e.recipe_mealplan.servings
|
||||
}} {{ $t('Servings') }})<br/>
|
||||
</span>
|
||||
<span
|
||||
v-if="e.recipe_mealplan && e.recipe_mealplan.mealplan_type !== undefined"> {{
|
||||
e.recipe_mealplan.mealplan_type
|
||||
}} {{ formatDate(e.recipe_mealplan.mealplan_from_date) }} <br/></span>
|
||||
|
||||
{{ e.created_by.display_name }} {{ formatDate(e.created_at) }}<br/>
|
||||
</small></span>
|
||||
|
||||
</div>
|
||||
<b-button variant="outline-danger"
|
||||
@click="useShoppingListStore().deleteObject(e)"><i
|
||||
class="fas fa-trash"></i></b-button>
|
||||
</b-button-group>
|
||||
|
||||
<generic-multiselect
|
||||
class="mt-1"
|
||||
v-if="e.recipe_mealplan === null"
|
||||
:initial_single_selection="e.unit"
|
||||
:model="Models.UNIT"
|
||||
:multiple="false"
|
||||
@change="e.unit = $event.val; useShoppingListStore().updateObject(e)"
|
||||
>
|
||||
</generic-multiselect>
|
||||
|
||||
<number-scaler-component :number="e.amount"
|
||||
@change="e.amount = $event; useShoppingListStore().updateObject(e)"
|
||||
v-if="e.recipe_mealplan === null"></number-scaler-component>
|
||||
<hr class="m-2"/>
|
||||
</b-col>
|
||||
|
||||
<b-col cols="6" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatFood }}
|
||||
</b-col>
|
||||
<b-col cols="3" data-html2canvas-ignore="true" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
|
||||
class="align-items-center d-none d-md-flex justify-content-end">
|
||||
<b-button size="sm" @click="showDetails = !showDetails"
|
||||
class="p-0 mr-0 mr-md-2 p-md-2 text-decoration-none" variant="link">
|
||||
<div class="text-nowrap">
|
||||
<i class="fa fa-chevron-right rotate" :class="showDetails ? 'rotated' : ''"></i>
|
||||
<span class="d-none d-md-inline-block"><span class="ml-2">{{
|
||||
$t("Details")
|
||||
}}</span></span>
|
||||
</div>
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="2" class="justify-content-start align-items-center d-flex d-md-none pl-0 pr-0" v-touch:start="startHandler" v-touch:moving="moveHandler" v-touch:end="endHandler"
|
||||
v-if="!settings.left_handed">
|
||||
<b-button size="sm" @click="showDetails = !showDetails" class="d-inline-block d-md-none p-0"
|
||||
variant="link">
|
||||
<div class="text-nowrap"><i class="fa fa-chevron-right rotate"
|
||||
:class="showDetails ? 'rotated' : ''"></i></div>
|
||||
|
||||
<b-button variant="success" block @click="useShoppingListStore().createObject({ amount: 0, unit: null, food: food, })"> {{ $t("Add") }}</b-button>
|
||||
<b-button variant="warning" block @click="detail_modal_visible = false; setFoodIgnoredAndChecked(food)"> {{ $t("Ignore_Shopping") }}</b-button>
|
||||
<b-button variant="danger" block class="mt-2"
|
||||
@click="detail_modal_visible = false;useShoppingListStore().deleteEntries(entries)">
|
||||
{{ $t('Delete_All') }}
|
||||
</b-button>
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="center" class="d-none d-md-flex">
|
||||
<b-col cols="12">
|
||||
<div class="small text-muted text-truncate">{{ formatHint }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<!-- detail rows -->
|
||||
<div class="card no-body mb-1 pt-2 align-content-center shadow-sm" v-if="showDetails">
|
||||
<div v-for="(e, x) in entries" :key="e.id">
|
||||
<b-row class="small justify-content-around">
|
||||
<b-col cols="auto" md="4" class="overflow-hidden text-nowrap">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
class="btn btn-link btn-sm m-0 p-0 pl-2"
|
||||
style="text-overflow: ellipsis"
|
||||
@click.stop="openRecipeCard($event, e)"
|
||||
@mouseover="openRecipeCard($event, e)"
|
||||
>
|
||||
{{ formatOneRecipe(e) }}
|
||||
</button>
|
||||
</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted">{{ formatOneMealPlan(e) }}</b-col>
|
||||
<b-col cols="auto" md="4" class="text-muted text-right overflow-hidden text-nowrap pr-4">
|
||||
{{ formatOneCreatedBy(e) }}
|
||||
<div v-if="formatOneCompletedAt(e)">{{ formatOneCompletedAt(e) }}</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<b-row align-h="start">
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none pr-0"
|
||||
v-if="settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="2" md="1" class="align-items-center d-flex">
|
||||
<div class="dropdown b-dropdown position-static inline-block" data-html2canvas-ignore="true"
|
||||
@click.stop="$emit('open-context-menu', $event, e)">
|
||||
<button
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
type="button"
|
||||
:class="settings.left_handed ? 'dropdown-spacing' : ''"
|
||||
class="btn dropdown-toggle btn-link text-decoration-none text-body pr-1 pr-md-3 pl-md-3 dropdown-toggle-no-caret"
|
||||
>
|
||||
<i class="fas fa-ellipsis-v fa-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</b-col>
|
||||
<b-col cols="1" class="justify-content-center align-items-center d-none d-md-flex">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control"
|
||||
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
|
||||
</b-col>
|
||||
<b-col cols="7" md="9">
|
||||
<b-row class="d-flex align-items-center h-100">
|
||||
<b-col cols="5" md="3" class="d-flex align-items-center">
|
||||
<strong class="mr-1">{{ formatOneAmount(e) }}</strong> {{ formatOneUnit(e) }}
|
||||
</b-col>
|
||||
<b-col cols="7" md="6" class="align-items-center d-flex pl-0 pr-0 pl-md-2 pr-md-2">
|
||||
{{ formatOneFood(e) }}
|
||||
</b-col>
|
||||
<b-col cols="12" class="d-flex d-md-none">
|
||||
<div class="small text-muted text-truncate" v-for="(n, i) in formatOneNote(e)"
|
||||
:key="i">{{ n }}
|
||||
</div>
|
||||
</b-col>
|
||||
</b-row>
|
||||
</b-col>
|
||||
<b-col cols="3" md="2" class="justify-content-start align-items-center d-flex d-md-none"
|
||||
v-if="!settings.left_handed">
|
||||
<input type="checkbox" class="form-control form-control-sm checkbox-control-mobile"
|
||||
:checked="formatChecked" @change="updateChecked" :key="entries[0].id"/>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<hr class="w-75 mt-1 mb-1 mt-md-3 mb-md-3" v-if="x !== entries.length - 1"/>
|
||||
<div class="pb-1 pb-md-4" v-if="x === entries.length - 1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="m-1" v-if="!showDetails"/>
|
||||
<ContextMenu ref="recipe_card" triggers="click, hover" :title="$t('Filters')" style="max-width: 300">
|
||||
<template #menu="{ contextData }" v-if="recipe">
|
||||
<ContextMenuItem>
|
||||
<RecipeCard :recipe="contextData" :detail="false"></RecipeCard>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem @click="$refs.menu.close()">
|
||||
<b-form-group label-cols="9" content-cols="3" class="text-nowrap m-0 mr-2">
|
||||
<template #label>
|
||||
<a class="dropdown-item p-2" href="#"><i class="fas fa-pizza-slice"></i>
|
||||
{{ $t("Servings") }}</a>
|
||||
</template>
|
||||
<div @click.prevent.stop>
|
||||
<b-form-input class="mt-2" min="0" type="number" v-model="servings"></b-form-input>
|
||||
</div>
|
||||
</b-form-group>
|
||||
</ContextMenuItem>
|
||||
</template>
|
||||
</ContextMenu>
|
||||
<i class="fa fa-hourglass fa-lg" style="display: none; position: absolute" aria-hidden="true"
|
||||
ref="delay_icon"></i>
|
||||
<i class="fa fa-check fa-lg" style="display: none; position: absolute" aria-hidden="true" ref="check_icon"></i>
|
||||
|
||||
<template #modal-footer>
|
||||
<span></span>
|
||||
</template>
|
||||
</b-modal>
|
||||
|
||||
<generic-modal-form :model="Models.FOOD" :show="editing_food !== null"
|
||||
@hidden="editing_food = null; useShoppingListStore().refreshFromAPI()"></generic-modal-form>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -178,292 +136,237 @@
|
||||
import Vue from "vue"
|
||||
import {BootstrapVue} from "bootstrap-vue"
|
||||
import "bootstrap-vue/dist/bootstrap-vue.css"
|
||||
import ContextMenu from "@/components/ContextMenu/ContextMenu"
|
||||
import ContextMenuItem from "@/components/ContextMenu/ContextMenuItem"
|
||||
import {ApiMixin} from "@/utils/utils"
|
||||
import RecipeCard from "./RecipeCard.vue"
|
||||
import Vue2TouchEvents from "vue2-touch-events"
|
||||
import {ApiMixin, FormatMixin, resolveDjangoUrl, StandardToasts} from "@/utils/utils"
|
||||
import {useMealPlanStore} from "@/stores/MealPlanStore";
|
||||
import {useShoppingListStore} from "@/stores/ShoppingListStore";
|
||||
import {ApiApiFactory} from "@/utils/openapi/api";
|
||||
import {useUserPreferenceStore} from "@/stores/UserPreferenceStore";
|
||||
import NumberScalerComponent from "@/components/NumberScalerComponent.vue";
|
||||
import GenericModalForm from "@/components/Modals/GenericModalForm.vue";
|
||||
import GenericMultiselect from "@/components/GenericMultiselect.vue";
|
||||
|
||||
|
||||
Vue.use(BootstrapVue)
|
||||
Vue.use(Vue2TouchEvents)
|
||||
|
||||
export default {
|
||||
// TODO ApiGenerator doesn't capture and share error information - would be nice to share error details when available
|
||||
// or i'm capturing it incorrectly
|
||||
name: "ShoppingLineItem",
|
||||
mixins: [ApiMixin],
|
||||
components: {RecipeCard, ContextMenu, ContextMenuItem},
|
||||
mixins: [ApiMixin, FormatMixin],
|
||||
components: {GenericMultiselect, GenericModalForm, NumberScalerComponent},
|
||||
props: {
|
||||
entries: {
|
||||
type: Array,
|
||||
},
|
||||
settings: Object,
|
||||
groupby: {type: String},
|
||||
entries: {type: Object,},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showDetails: false,
|
||||
recipe: undefined,
|
||||
servings: 1,
|
||||
dragStartX: 0,
|
||||
distance_left: 0
|
||||
detail_modal_visible: false,
|
||||
editing_food: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
formatAmount: function () {
|
||||
let amount = {}
|
||||
this.entries.forEach((entry) => {
|
||||
let unit = entry?.unit?.name ?? "---"
|
||||
if (entry.amount) {
|
||||
if (amount[unit]) {
|
||||
amount[unit] += entry.amount
|
||||
} else {
|
||||
amount[unit] = entry.amount
|
||||
item_container_id: function () {
|
||||
let id = 'id_sli_'
|
||||
for (let i in this.entries) {
|
||||
id += i + '_'
|
||||
}
|
||||
return id
|
||||
},
|
||||
is_checked: function () {
|
||||
for (let i in this.entries) {
|
||||
if (!this.entries[i].checked) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
is_delayed: function () {
|
||||
for (let i in this.entries) {
|
||||
if (Date.parse(this.entries[i].delay_until) > new Date(Date.now())) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
food: function () {
|
||||
return this.entries[Object.keys(this.entries)[0]]['food']
|
||||
},
|
||||
amounts: function () {
|
||||
let unit_amounts = {}
|
||||
|
||||
for (let i in this.entries) {
|
||||
let e = this.entries[i]
|
||||
|
||||
if (!e.checked && e.delay_until === null
|
||||
|| (e.checked && useUserPreferenceStore().device_settings.shopping_show_checked_entries)
|
||||
|| (e.delay_until !== null && useUserPreferenceStore().device_settings.shopping_show_delayed_entries)) {
|
||||
|
||||
let unit = -1
|
||||
if (e.unit !== undefined && e.unit !== null) {
|
||||
unit = e.unit.id
|
||||
}
|
||||
if (e.amount > 0) {
|
||||
if (unit in unit_amounts) {
|
||||
unit_amounts[unit]['amount'] += e.amount
|
||||
} else {
|
||||
if (unit === -1) {
|
||||
unit_amounts[unit] = {id: -1, unit: "", amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
|
||||
} else {
|
||||
unit_amounts[unit] = {id: e.unit.id, unit: e.unit.name, amount: e.amount, checked: e.checked, delayed: (e.delay_until !== null)}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
for (const [k, v] of Object.entries(amount)) {
|
||||
amount[k] = Math.round(v * 100 + Number.EPSILON) / 100 // javascript hack to force rounding at 2 places
|
||||
}
|
||||
return amount
|
||||
return unit_amounts
|
||||
},
|
||||
formatCategory: function () {
|
||||
return this.formatOneCategory(this.entries[0]) || this.$t("Undefined")
|
||||
food_row: function () {
|
||||
return this.food.name
|
||||
},
|
||||
formatChecked: function () {
|
||||
return this.entries.map((x) => x.checked).every((x) => x === true)
|
||||
},
|
||||
formatHint: function () {
|
||||
if (this.groupby == "recipe") {
|
||||
return this.formatCategory
|
||||
} else {
|
||||
return this.formatRecipe
|
||||
}
|
||||
},
|
||||
formatFood: function () {
|
||||
return this.formatOneFood(this.entries[0])
|
||||
},
|
||||
formatUnit: function () {
|
||||
return this.formatOneUnit(this.entries[0])
|
||||
},
|
||||
formatRecipe: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneMealPlan(this.entries[0]) || ""
|
||||
} else {
|
||||
let mealplan_name = this.entries.filter((x) => x?.recipe_mealplan?.name)
|
||||
// return [this.formatOneMealPlan(mealplan_name?.[0]), this.$t("CountMore", { count: this.entries?.length - 1 })].join(" ")
|
||||
info_row: function () {
|
||||
let info_row = []
|
||||
|
||||
return mealplan_name
|
||||
.map((x) => {
|
||||
return this.formatOneMealPlan(x)
|
||||
})
|
||||
.join(" - ")
|
||||
let authors = []
|
||||
let recipes = []
|
||||
let meal_pans = []
|
||||
|
||||
for (let i in this.entries) {
|
||||
let e = this.entries[i]
|
||||
|
||||
if (authors.indexOf(e.created_by.display_name) === -1) {
|
||||
authors.push(e.created_by.display_name)
|
||||
}
|
||||
|
||||
|
||||
if (e.recipe_mealplan !== null) {
|
||||
let recipe_name = e.recipe_mealplan.recipe_name
|
||||
if (recipes.indexOf(recipe_name) === -1) {
|
||||
recipes.push(recipe_name.substring(0, 14) + (recipe_name.length > 14 ? '..' : ''))
|
||||
}
|
||||
|
||||
if ('mealplan_from_date' in e.recipe_mealplan) {
|
||||
let meal_plan_entry = (e?.recipe_mealplan?.mealplan_type || '') + ' (' + this.formatDate(e.recipe_mealplan.mealplan_from_date) + ')'
|
||||
if (meal_pans.indexOf(meal_plan_entry) === -1) {
|
||||
meal_pans.push(meal_plan_entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
formatNotes: function () {
|
||||
if (this.entries?.length == 1) {
|
||||
return this.formatOneNote(this.entries[0]) || ""
|
||||
|
||||
if (useUserPreferenceStore().device_settings.shopping_item_info_created_by && authors.length > 0) {
|
||||
info_row.push(authors.join(', '))
|
||||
}
|
||||
return ""
|
||||
},
|
||||
if (useUserPreferenceStore().device_settings.shopping_item_info_recipe && recipes.length > 0) {
|
||||
info_row.push(recipes.join(', '))
|
||||
}
|
||||
if (useUserPreferenceStore().device_settings.shopping_item_info_mealplan && meal_pans.length > 0) {
|
||||
info_row.push(meal_pans.join(', '))
|
||||
}
|
||||
|
||||
return info_row.join(' - ')
|
||||
}
|
||||
},
|
||||
watch: {},
|
||||
mounted() {
|
||||
this.servings = this.entries?.[0]?.recipe_mealplan?.servings ?? 0
|
||||
|
||||
},
|
||||
methods: {
|
||||
// this.genericAPI inherited from ApiMixin
|
||||
|
||||
formatDate: function (datetime) {
|
||||
if (!datetime) {
|
||||
return
|
||||
useUserPreferenceStore,
|
||||
useShoppingListStore,
|
||||
resolveDjangoUrl,
|
||||
/**
|
||||
* update the food after the category was changed
|
||||
* handle changing category to category ID as a workaround
|
||||
* @param food
|
||||
*/
|
||||
updateFoodCategory: function (food) {
|
||||
if (typeof food.supermarket_category === "number") { // not the best solution, but as long as generic multiselect does not support caching, I don't want to use a proper model
|
||||
food.supermarket_category = this.useShoppingListStore().supermarket_categories.filter(sc => sc.id === food.supermarket_category)[0]
|
||||
}
|
||||
return Intl.DateTimeFormat(window.navigator.language, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(Date.parse(datetime))
|
||||
},
|
||||
startHandler: function (event) {
|
||||
if (event.changedTouches.length > 0) {
|
||||
this.dragStartX = event.changedTouches[0].clientX
|
||||
}
|
||||
},
|
||||
getOffset(el) {
|
||||
let rect = el.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left + window.scrollX,
|
||||
top: rect.top + window.scrollY,
|
||||
right: rect.right - window.scrollX,
|
||||
};
|
||||
},
|
||||
moveHandler: function (event) {
|
||||
let item = this.$refs['shopping_line_item'];
|
||||
this.distance_left = event.changedTouches[0].clientX - this.dragStartX;
|
||||
item.style.marginLeft = this.distance_left
|
||||
item.style.marginRight = -this.distance_left
|
||||
item.style.backgroundColor = '#ddbf86'
|
||||
item.style.border = "1px solid #000"
|
||||
|
||||
let delay_icon = this.$refs['delay_icon']
|
||||
let check_icon = this.$refs['check_icon']
|
||||
let apiClient = new ApiApiFactory()
|
||||
apiClient.updateFood(food.id, food).then(r => {
|
||||
|
||||
let color_factor = Math.abs(this.distance_left) / 100
|
||||
|
||||
if (this.distance_left > 0) {
|
||||
item.parentElement.parentElement.style.backgroundColor = 'rgba(130,170,139,0)'.replace(/[^,]+(?=\))/, color_factor)
|
||||
check_icon.style.display = "block"
|
||||
check_icon.style.left = this.getOffset(item.parentElement.parentElement).left + 40
|
||||
check_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
|
||||
check_icon.style.opacity = color_factor - 0.3
|
||||
} else {
|
||||
item.parentElement.parentElement.style.backgroundColor = 'rgba(185,135,102,0)'.replace(/[^,]+(?=\))/, color_factor)
|
||||
delay_icon.style.display = "block"
|
||||
delay_icon.style.left = this.getOffset(item.parentElement.parentElement).right - 40
|
||||
delay_icon.style.top = this.getOffset(item.parentElement.parentElement).top - 92
|
||||
delay_icon.style.opacity = color_factor - 0.3
|
||||
}
|
||||
},
|
||||
endHandler: function (event) {
|
||||
let item = this.$refs['shopping_line_item'];
|
||||
item.removeAttribute('style');
|
||||
item.parentElement.parentElement.removeAttribute('style');
|
||||
|
||||
let delay_icon = this.$refs['delay_icon']
|
||||
let check_icon = this.$refs['check_icon']
|
||||
|
||||
delay_icon.style.display = "none"
|
||||
check_icon.style.display = "none"
|
||||
|
||||
if (Math.abs(this.distance_left) > window.screen.width / 6) {
|
||||
if (this.distance_left > 0) {
|
||||
let checked = false;
|
||||
this.entries.forEach((cur) => {
|
||||
checked = cur.checked
|
||||
})
|
||||
let update = {entries: this.entries.map((x) => x.id), checked: !checked}
|
||||
this.$emit("update-checkbox", update)
|
||||
} else {
|
||||
this.$emit("update-delaythis", this.entries)
|
||||
}
|
||||
}
|
||||
},
|
||||
formatOneAmount: function (item) {
|
||||
return item?.amount ?? 1
|
||||
},
|
||||
formatOneUnit: function (item) {
|
||||
return item?.unit?.name ?? ""
|
||||
},
|
||||
formatOneCategory: function (item) {
|
||||
return item?.food?.supermarket_category?.name
|
||||
},
|
||||
formatOneCompletedAt: function (item) {
|
||||
if (!item.completed_at) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("Completed"), "@", this.formatDate(item.completed_at)].join(" ")
|
||||
},
|
||||
formatOneFood: function (item) {
|
||||
return item.food.name
|
||||
},
|
||||
formatOneDelayUntil: function (item) {
|
||||
if (!item.delay_until || (item.delay_until && item.checked)) {
|
||||
return false
|
||||
}
|
||||
return [this.$t("DelayUntil"), "-", this.formatDate(item.delay_until)].join(" ")
|
||||
},
|
||||
formatOneMealPlan: function (item) {
|
||||
return item?.recipe_mealplan?.name ?? ""
|
||||
},
|
||||
formatOneRecipe: function (item) {
|
||||
return item?.recipe_mealplan?.recipe_name ?? ""
|
||||
},
|
||||
formatOneNote: function (item) {
|
||||
if (!item) {
|
||||
item = this.entries[0]
|
||||
}
|
||||
return [item?.recipe_mealplan?.mealplan_note, item?.ingredient_note].filter(String)
|
||||
},
|
||||
formatOneCreatedBy: function (item) {
|
||||
return [this.$t("Added_by"), item?.created_by.display_name, "@", this.formatDate(item.created_at)].join(" ")
|
||||
},
|
||||
openRecipeCard: function (e, item) {
|
||||
this.genericAPI(this.Models.RECIPE, this.Actions.FETCH, {id: item.recipe_mealplan.recipe}).then((result) => {
|
||||
let recipe = result.data
|
||||
recipe.steps = undefined
|
||||
this.recipe = true
|
||||
this.$refs.recipe_card.open(e, recipe)
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
},
|
||||
updateChecked: function (e, item) {
|
||||
let update = undefined
|
||||
if (!item) {
|
||||
update = {entries: this.entries.map((x) => x.id), checked: !this.formatChecked}
|
||||
} else {
|
||||
update = {entries: [item], checked: !item.checked}
|
||||
}
|
||||
console.log(update)
|
||||
this.$emit("update-checkbox", update)
|
||||
/**
|
||||
* set food on_hand status to true and check all associated entries
|
||||
* @param food
|
||||
*/
|
||||
setFoodIgnoredAndChecked: function (food) {
|
||||
let apiClient = new ApiApiFactory()
|
||||
|
||||
food.ignore_shopping = true
|
||||
apiClient.updateFood(food.id, food).then(r => {
|
||||
}).catch((err) => {
|
||||
StandardToasts.makeStandardToast(this, StandardToasts.FAIL_UPDATE, err)
|
||||
})
|
||||
|
||||
useShoppingListStore().setEntriesCheckedState(this.entries, true, false)
|
||||
},
|
||||
/**
|
||||
* function triggered by touchend event of swipe container
|
||||
* check if min distance is reached and execute desired action
|
||||
*/
|
||||
handleSwipe: function () {
|
||||
const minDistance = 80;
|
||||
const container = document.querySelector('#' + this.item_container_id);
|
||||
// get the distance the user swiped
|
||||
const swipeDistance = container.scrollLeft - container.clientWidth;
|
||||
if (swipeDistance < minDistance * -1) {
|
||||
useShoppingListStore().setEntriesCheckedState(this.entries, !this.is_checked, true)
|
||||
} else if (swipeDistance > minDistance) {
|
||||
useShoppingListStore().delayEntries(this.entries, !this.is_delayed, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--style src="vue-multiselect/dist/vue-multiselect.min.css"></style-->
|
||||
|
||||
<style>
|
||||
/* table { border-collapse:collapse } /* Ensure no space between cells */
|
||||
/* tr.strikeout td { position:relative } /* Setup a new coordinate system */
|
||||
/* tr.strikeout td:before { /* Create a new element that */
|
||||
/* content: " "; /* …has no text content */
|
||||
/* position: absolute; /* …is absolutely positioned */
|
||||
/* left: 0; top: 50%; width: 100%; /* …with the top across the middle */
|
||||
/* border-bottom: 1px solid #000; /* …and with a border on the top */
|
||||
/* } */
|
||||
.checkbox-control {
|
||||
font-size: 0.6rem;
|
||||
/* scroll snap takes care of restoring scroll position */
|
||||
.swipe-container {
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
overflow-x: scroll;
|
||||
scroll-snap-type: x mandatory;
|
||||
}
|
||||
|
||||
.checkbox-control-mobile {
|
||||
font-size: 1rem;
|
||||
/* scrollbar should be hidden */
|
||||
.swipe-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.rotate {
|
||||
-moz-transition: all 0.25s linear;
|
||||
-webkit-transition: all 0.25s linear;
|
||||
transition: all 0.25s linear;
|
||||
.swipe-container {
|
||||
scrollbar-width: none; /* For Firefox */
|
||||
}
|
||||
|
||||
.rotated {
|
||||
-moz-transform: rotate(90deg);
|
||||
-webkit-transform: rotate(90deg);
|
||||
transform: rotate(90deg);
|
||||
/* main element should always snap into view */
|
||||
.swipe-element {
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.unit-badge-lg {
|
||||
font-size: 1rem !important;
|
||||
font-weight: 500 !important;
|
||||
.swipe-icon {
|
||||
color: white;
|
||||
position: sticky;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dropdown-spacing {
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
/* swipe-actions and element should be 100% wide */
|
||||
.swipe-action,
|
||||
.swipe-element {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.invis-border {
|
||||
border: 1px solid transparent;
|
||||
.swipe-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
.fa-ellipsis-v {
|
||||
font-size: 20px;
|
||||
}
|
||||
.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.fa-ellipsis-v {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user