moved keyword Vue to generic components

This commit is contained in:
smilerz 2021-09-05 16:26:01 -05:00
parent 638dd96812
commit f12558951a
27 changed files with 476 additions and 156263 deletions

View File

@ -1 +0,0 @@
.shake[data-v-d394ab04]{-webkit-animation:shake-data-v-d394ab04 .82s cubic-bezier(.36,.07,.19,.97) both;animation:shake-data-v-d394ab04 .82s cubic-bezier(.36,.07,.19,.97) both;transform:translateZ(0);-webkit-backface-visibility:hidden;backface-visibility:hidden;perspective:1000px}@-webkit-keyframes shake-data-v-d394ab04{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}@keyframes shake-data-v-d394ab04{10%,90%{transform:translate3d(-1px,0,0)}20%,80%{transform:translate3d(2px,0,0)}30%,50%,70%{transform:translate3d(-4px,0,0)}40%,60%{transform:translate3d(4px,0,0)}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Vue App</title><link href="css/chunk-vendors.css" rel="preload" as="style"><link href="css/keyword_list_view.css" rel="preload" as="style"><link href="js/chunk-vendors.js" rel="preload" as="script"><link href="js/keyword_list_view.js" rel="preload" as="script"><link href="css/chunk-vendors.css" rel="stylesheet"><link rel="icon" type="image/png" sizes="32x32" href="img/icons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="img/icons/favicon-16x16.png"><link rel="manifest" href="manifest.json"><meta name="theme-color" content="#4DBA87"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><meta name="apple-mobile-web-app-title" content="Recipes"><link rel="apple-touch-icon" href="img/icons/apple-touch-icon-152x152.png"><link rel="mask-icon" href="img/icons/safari-pinned-tab.svg" color="#4DBA87"><meta name="msapplication-TileImage" content="img/icons/msapplication-icon-144x144.png"><meta name="msapplication-TileColor" content="#000000"></head><body><div id="app"></div><script src="js/chunk-vendors.js"></script></body></html>

View File

@ -1,31 +0,0 @@
{% extends "base.html" %}
{% load render_bundle from webpack_loader %}
{% load static %}
{% load i18n %}
{% load l10n %}
{% comment %} TODO Can this be combined with Food template? {% endcomment %}
{% block title %}{% trans 'Keywords' %}{% endblock %}
{% block content_fluid %}
<div id="app" >
<keyword-list-view></keyword-list-view>
</div>
{% endblock %}
{% block script %}
{% if debug %}
<script src="{% url 'js_reverse' %}"></script>
{% else %}
<script src="{% static 'django_js_reverse/reverse.js' %}"></script>
{% endif %}
<script type="application/javascript">
window.IMAGE_PLACEHOLDER = "{% static 'assets/recipe_no_image.svg' %}"
</script>
{% render_bundle 'keyword_list_view' %}
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@ -105,7 +105,17 @@ def invite_link(request):
@group_required('user')
def keyword(request):
return render(request, 'model/keyword_template.html', {"title": _("Keywords")})
return render(
request,
'generic/model_template.html',
{
"title": _("Keywords"),
"config": {
'model': "KEYWORD",
'recipe_param': 'keywords'
}
}
)
@group_required('user')

13
vue/package-lock.json generated
View File

@ -44,7 +44,7 @@
"eslint-plugin-vue": "^7.10.0",
"typescript": "~4.3.2",
"vue-cli-plugin-i18n": "^2.1.1",
"webpack-bundle-tracker": "1.1.0",
"webpack-bundle-tracker": "1.3.0",
"workbox-expiration": "^6.0.2",
"workbox-navigation-preload": "^6.0.2",
"workbox-precaching": "^6.0.2",
@ -14289,15 +14289,15 @@
}
},
"node_modules/webpack-bundle-tracker": {
"version": "1.1.0",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-1.3.0.tgz",
"integrity": "sha512-cs3QMgW5F0mE0e91X/SuEq2MUfu2LqpjFSdfINsOmNt/T4v39EESNhJ+P8d5lmbfFL6Z1z7n6LlpVCERTdDDvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lodash.assign": "^4.2.0",
"lodash.defaults": "^4.2.0",
"lodash.foreach": "^4.5.0",
"lodash.get": "^4.4.2",
"lodash.merge": "^4.6.2",
"strip-ansi": "^6.0.0"
}
},
@ -24887,14 +24887,15 @@
}
},
"webpack-bundle-tracker": {
"version": "1.1.0",
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/webpack-bundle-tracker/-/webpack-bundle-tracker-1.3.0.tgz",
"integrity": "sha512-cs3QMgW5F0mE0e91X/SuEq2MUfu2LqpjFSdfINsOmNt/T4v39EESNhJ+P8d5lmbfFL6Z1z7n6LlpVCERTdDDvQ==",
"dev": true,
"requires": {
"lodash.assign": "^4.2.0",
"lodash.defaults": "^4.2.0",
"lodash.foreach": "^4.5.0",
"lodash.get": "^4.4.2",
"lodash.merge": "^4.6.2",
"strip-ansi": "^6.0.0"
}
},

View File

@ -1,605 +0,0 @@
<template>
<div id="app" style="margin-bottom: 4vh">
<div class="row">
<div class="col-md-2 d-none d-md-block">
</div>
<div class="col-xl-8 col-12">
<!-- TODO only show scollbars in split mode, but this doesn't interact well with infinite scroll, maybe a different component? -->
<div class="container-fluid d-flex flex-column flex-grow-1" :class="{'vh-100' : show_split}">
<!-- expanded options box -->
<div class="row flex-shrink-0">
<div class="col col-md-12">
<b-collapse id="collapse_advanced" class="mt-2" v-model="advanced_visible">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-3" style="margin-top: 1vh">
<div class="btn btn-primary btn-block text-uppercase" @click="startAction({'action':'new'})">
{{ this.$t('New_Keyword') }}
</div>
</div>
<div class="col-md-3" style="margin-top: 1vh">
<button class="btn btn-primary btn-block text-uppercase" @click="resetSearch">
{{ this.$t('Reset_Search') }}
</button>
</div>
<div class="col-md-3" style="position: relative; margin-top: 1vh">
<b-form-checkbox v-model="show_split" name="check-button"
class="shadow-none"
style="position:relative;top: 50%; transform: translateY(-50%);" switch>
{{ this.$t('show_split_screen') }}
</b-form-checkbox>
</div>
</div>
</div>
</div>
</b-collapse>
</div>
</div>
<div class="row flex-shrink-0">
<!-- search box -->
<div class="col col-md">
<b-input-group class="mt-3">
<b-input class="form-control" v-model="search_input"
v-bind:placeholder="this.$t('Search')"></b-input>
<b-input-group-append>
<b-button v-b-toggle.collapse_advanced variant="primary" class="shadow-none">
<i class="fas fa-caret-down" v-if="!advanced_visible"></i>
<i class="fas fa-caret-up" v-if="advanced_visible"></i>
</b-button>
</b-input-group-append>
</b-input-group>
</div>
<!-- split side search -->
<div class="col col-md" v-if="show_split">
<b-input-group class="mt-3">
<b-input class="form-control" v-model="search_input2"
v-bind:placeholder="this.$t('Search')"></b-input>
</b-input-group>
</div>
</div>
<!-- only show scollbars in split mode, but this doesn't interact well with infinite scroll, maybe a different componenet? -->
<div class="row" :class="{'overflow-hidden' : show_split}" style="margin-top: 2vh">
<div class="col col-md" :class="{'mh-100 overflow-auto' : show_split}">
<generic-horizontal-card v-for="kw in keywords" v-bind:key="kw.id"
:item=kw
item_type="Keyword"
:draggable="true"
:merge="true"
:move="true"
@item-action="startAction($event, 'left')"
/>
<infinite-loading
:identifier='left'
@infinite="infiniteHandler($event, 'left')"
spinner="waveDots">
<template v-slot:no-more><span/></template>
</infinite-loading>
</div>
<!-- right side keyword cards -->
<div class="col col-md mh-100 overflow-auto " v-if="show_split">
<generic-horizontal-card v-for="kw in keywords2" v-bind:key="kw.id"
:item=kw
item_type="Keyword"
:draggable="true"
:merge="true"
:move="true"
@item-action="startAction($event, 'right')"
/>
<infinite-loading
:identifier='right'
@infinite="infiniteHandler($event, 'right')"
spinner="waveDots">
<template v-slot:no-more><span/></template>
</infinite-loading>
</div>
</div>
</div>
</div>
<div class="col-md-2 d-none d-md-block">
</div>
</div>
<!-- TODO Modals can probably be made generic and moved to component -->
<!-- edit modal -->
<b-modal class="modal"
:id="'id_modal_keyword_edit'"
@shown="prepareEmoji"
:title="this.$t('Edit_Keyword')"
:ok-title="this.$t('Save')"
:cancel-title="this.$t('Cancel')"
@ok="saveKeyword">
<form>
<label for="id_keyword_name_edit">{{ this.$t('Name') }}</label>
<input class="form-control" type="text" id="id_keyword_name_edit" v-model="this_item.name">
<label for="id_keyword_description_edit">{{ this.$t('Description') }}</label>
<input class="form-control" type="text" id="id_keyword_description_edit" v-model="this_item.description">
<label for="id_keyword_icon_edit">{{ this.$t('Icon') }}</label>
<twemoji-textarea
id="id_keyword_icon_edit"
ref="_edit"
:emojiData="emojiDataAll"
:emojiGroups="emojiGroups"
triggerType="hover"
recentEmojisFeat="true"
recentEmojisStorage="local"
@contentChanged="setIcon"
/>
</form>
</b-modal>
<!-- delete modal -->
<b-modal class="modal"
:id="'id_modal_keyword_delete'"
:title="this.$t('Delete_Keyword')"
:ok-title="this.$t('Delete')"
:cancel-title="this.$t('Cancel')"
@ok="delKeyword(this_item.id)">
{{this.$t("delete_confimation", {'kw': this_item.name})}} {{this_item.name}}
</b-modal>
<!-- move modal -->
<b-modal class="modal" v-if="models"
:id="'id_modal_keyword_move'"
:title="this.$t('Move_Keyword')"
:ok-title="this.$t('Move')"
:cancel-title="this.$t('Cancel')"
@ok="moveKeyword(this_item.id, this_item.target.id)">
{{ this.$t("move_selection", {'child': this_item.name}) }}
<generic-multiselect
@change="this_item.target=$event.val"
label="name"
:model="models.KEYWORD"
:multiple="false"
:sticky_options="[{'id': 0,'name': $t('Root')}]"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="this.$t('Search')">
</generic-multiselect>
</b-modal>
<!-- merge modal -->
<b-modal class="modal" v-if="models"
:id="'id_modal_keyword_merge'"
:title="this.$t('Merge_Keyword')"
:ok-title="this.$t('Merge')"
:cancel-title="this.$t('Cancel')"
@ok="mergeKeyword(this_item.id, this_item.target.id)">
{{ this.$t("merge_selection", {'source': this_item.name, 'type': this.$t('keyword')}) }}
<generic-multiselect
@change="this_item.target=$event.val"
:model="models.KEYWORD"
:multiple="false"
style="flex-grow: 1; flex-shrink: 1; flex-basis: 0"
:placeholder="this.$t('Search')">
</generic-multiselect>
</b-modal>
</div>
</template>
<script>
import axios from "axios";
axios.defaults.xsrfCookieName = 'csrftoken'
axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"
import Vue from 'vue'
import {BootstrapVue} from 'bootstrap-vue'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import _debounce from 'lodash/debounce'
import {ToastMixin} from "@/utils/utils";
import {ApiApiFactory} from "@/utils/openapi/api.ts";
import GenericHorizontalCard from "@/components/GenericHorizontalCard";
import GenericMultiselect from "@/components/GenericMultiselect";
import InfiniteLoading from 'vue-infinite-loading';
// would move with modals if made generic
import {TwemojiTextarea} from '@kevinfaguiar/vue-twemoji-picker';
// TODO add localization
import EmojiAllData from '@kevinfaguiar/vue-twemoji-picker/emoji-data/en/emoji-all-groups.json';
import EmojiGroups from '@kevinfaguiar/vue-twemoji-picker/emoji-data/emoji-groups.json';
// end move with generic modals
Vue.use(BootstrapVue)
import {Models} from "@/utils/models";
export default {
name: 'KeywordListView',
mixins: [ToastMixin],
components: {TwemojiTextarea, GenericHorizontalCard, GenericMultiselect, InfiniteLoading},
computed: {
// move with generic modals
emojiDataAll() {
return EmojiAllData;
},
emojiGroups() {
return EmojiGroups;
}
// end move with generic modals
},
data() {
return {
keywords: [],
keywords2: [],
models: Models,
show_split: false,
search_input: '',
search_input2: '',
advanced_visible: false,
right_page: 0,
right: +new Date(),
isDirtyRight: false,
left_page: 0,
left: +new Date(),
isDirtyLeft: false,
this_item: {
'id': -1,
'name': '',
'description': '',
'icon': '',
'target': {
'id': -1,
'name': ''
},
},
}
},
watch: {
search_input: _debounce(function() {
this.left_page = 0
this.keywords = []
this.left += 1
}, 700),
search_input2: _debounce(function() {
this.right_page = 0
this.keywords2 = []
this.right += 1
}, 700)
},
methods: {
resetSearch: function () {
if (this.search_input !== '') {
this.search_input = ''
} else {
this.left_page = 0
this.keywords = []
this.left += 1
}
if (this.search_input2 !== '') {
this.search_input2 = ''
} else {
this.right_page = 0
this.keywords2 = []
this.right += 1
}
},
// TODO should model actions be included with the context menu? the card? a seperate mixin avaible to all?
startAction: function(e, col) {
let target = e.target || null
let source = e.source || null
if (e.action == 'delete') {
this.this_item = source
this.$bvModal.show('id_modal_keyword_delete')
} else if (e.action == 'new') {
this.this_item = {}
this.$bvModal.show('id_modal_keyword_edit')
} else if (e.action == 'edit') {
this.this_item = source
this.$bvModal.show('id_modal_keyword_edit')
} else if (e.action === 'move') {
this.this_item = source
if (target == null) {
this.$bvModal.show('id_modal_keyword_move')
} else {
this.moveKeyword(source.id, target.id)
}
} else if (e.action === 'merge') {
this.this_item = source
if (target == null) {
this.$bvModal.show('id_modal_keyword_merge')
} else {
this.mergeKeyword(e.source.id, e.target.id)
}
} else if (e.action === 'get-children') {
if (source.show_children) {
Vue.set(source, 'show_children', false)
} else {
this.this_item = source
this.getChildren(col, source)
}
} else if (e.action === 'get-recipes') {
if (source.show_recipes) {
Vue.set(source, 'show_recipes', false)
} else {
this.this_item = source
this.getRecipes(col, source)
}
}
},
saveKeyword: function () {
let apiClient = new ApiApiFactory()
let kw = {
name: this.this_item.name,
description: this.this_item.description,
icon: this.this_item.icon,
}
if (!this.this_item.id) { // if there is no item id assume its a new item
apiClient.createKeyword(kw).then(result => {
// place all new keywords at the top of the list - could sort instead
this.keywords = [result.data].concat(this.keywords)
// this creates a deep copy to make sure that columns stay independent
if (this.show_split){
this.keywords2 = [JSON.parse(JSON.stringify(result.data))].concat(this.keywords2)
} else {
this.keywords2 = []
}
this.this_item={}
}).catch((err) => {
console.log(err)
this.this_item = {}
})
} else {
apiClient.partialUpdateKeyword(this.this_item.id, kw).then(result => {
this.refreshCard(this.this_item.id)
this.this_item={}
}).catch((err) => {
console.log(err)
this.this_item = {}
})
}
},
delKeyword: function (id) {
let apiClient = new ApiApiFactory()
apiClient.destroyKeyword(id).then(response => {
this.destroyCard(id)
}).catch((err) => {
console.log(err)
this.this_item = {}
})
},
moveKeyword: function (source_id, target_id) {
let apiClient = new ApiApiFactory()
apiClient.moveKeyword(String(source_id), String(target_id)).then(result => {
if (target_id === 0) {
let kw = this.findKeyword(this.keywords, source_id) || this.findKeyword(this.keywords2, source_id)
kw.parent = null
if (this.show_split){
this.destroyCard(source_id) // order matters, destroy old card before adding it back in at root
this.keywords = [kw].concat(this.keywords)
this.keywords2 = [JSON.parse(JSON.stringify(kw))].concat(this.keywords2)
} else {
this.destroyCard(source_id)
this.keywords = [kw].concat(this.keywords)
this.keywords2 = []
}
} else {
this.destroyCard(source_id)
this.refreshCard(target_id)
}
}).catch((err) => {
// TODO none of the error checking works because the openapi generated functions don't throw an error?
// or i'm capturing it incorrectly
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
},
mergeKeyword: function (source_id, target_id) {
let apiClient = new ApiApiFactory()
apiClient.mergeKeyword(String(source_id), String(target_id)).then(result => {
this.destroyCard(source_id)
this.refreshCard(target_id)
}).catch((err) => {
console.log('Error', err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
},
// TODO: DRY the listKeyword functions (refresh, get children, infinityHandler ) can probably all be consolidated into a single function
getChildren: function(col, kw){
let apiClient = new ApiApiFactory()
let parent = {}
let query = undefined
let page = undefined
let root = kw.id
let tree = undefined
let pageSize = 200
apiClient.listKeywords(query, root, tree, page, pageSize).then(result => {
if (col == 'left') {
parent = this.findKeyword(this.keywords, kw.id)
} else if (col == 'right'){
parent = this.findKeyword(this.keywords2, kw.id)
}
if (parent) {
Vue.set(parent, 'children', result.data.results)
Vue.set(parent, 'show_children', true)
Vue.set(parent, 'show_recipes', false)
}
}).catch((err) => {
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
},
getRecipes: function(col, kw){
let apiClient = new ApiApiFactory()
let parent = {}
let pageSize = 200
let keyword = String(kw.id)
apiClient.listRecipes(
undefined, keyword, undefined, undefined, undefined, undefined,
undefined, undefined, undefined, undefined, undefined, pageSize, undefined
).then(result => {
if (col == 'left') {
parent = this.findKeyword(this.keywords, kw.id)
} else if (col == 'right'){
parent = this.findKeyword(this.keywords2, kw.id)
}
if (parent) {
Vue.set(parent, 'recipes', result.data.results)
Vue.set(parent, 'show_recipes', true)
Vue.set(parent, 'show_children', false)
}
}).catch((err) => {
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
})
},
refreshCard: function(id){
let target = {}
let apiClient = new ApiApiFactory()
let idx = undefined
let idx2 = undefined
apiClient.retrieveKeyword(id).then(result => {
target = this.findKeyword(this.keywords, id) || this.findKeyword(this.keywords2, id)
if (target.parent) {
let parent = this.findKeyword(this.keywords, target.parent)
let parent2 = this.findKeyword(this.keywords2, target.parent)
if (parent) {
if (parent.show_children){
idx = parent.children.indexOf(parent.children.find(kw => kw.id === target.id))
Vue.set(parent.children, idx, result.data)
}
}
if (parent2){
if (parent2.show_children){
idx2 = parent2.children.indexOf(parent2.children.find(kw => kw.id === target.id))
// deep copy to force columns to be indepedent
Vue.set(parent2.children, idx2, JSON.parse(JSON.stringify(result.data)))
}
}
} else {
idx = this.keywords.indexOf(this.keywords.find(kw => kw.id === target.id))
idx2 = this.keywords2.indexOf(this.keywords2.find(kw => kw.id === target.id))
Vue.set(this.keywords, idx, result.data)
Vue.set(this.keywords2, idx2, JSON.parse(JSON.stringify(result.data)))
}
})
},
findKeyword: function(card_list, id){
let card_length = card_list?.length ?? 0
if (card_length == 0) {
return false
}
let cards = card_list.filter(obj => obj.id == id)
if (cards.length == 1) {
return cards[0]
} else if (cards.length == 0) {
for (const c of card_list.filter(x => x.show_children == true)) {
cards = this.findKeyword(c.children, id)
if (cards) {
return cards
}
}
} else {
console.log('something terrible happened')
}
},
// this would move with modals with mixin?
prepareEmoji: function() {
this.$refs._edit.addText(this.this_item.icon || '');
this.$refs._edit.blur()
document.getElementById('btn-emoji-default').disabled = true;
},
// this would move with modals with mixin?
setIcon: function(icon) {
this.this_item.icon = icon
},
infiniteHandler: function($state, col) {
let apiClient = new ApiApiFactory()
let query = (col==='left') ? this.search_input : this.search_input2
let page = (col==='left') ? this.left_page + 1 : this.right_page + 1
let root = undefined
let tree = undefined
let pageSize = undefined
if (query === '') {
query = undefined
root = 0
}
apiClient.listKeywords(query, root, tree, page, pageSize).then(result => {
if (result.data.results.length){
if (col ==='left') {
this.left_page+=1
this.keywords = this.keywords.concat(result.data.results)
$state.loaded();
if (this.keywords.length >= result.data.count) {
$state.complete();
}
} else if (col ==='right') {
this.right_page+=1
this.keywords2 = this.keywords2.concat(result.data.results)
$state.loaded();
if (this.keywords2.length >= result.data.count) {
$state.complete();
}
}
} else {
console.log('no data returned')
$state.complete();
}
}).catch((err) => {
console.log(err)
this.makeToast(this.$t('Error'), err.bodyText, 'danger')
$state.complete();
})
},
destroyCard: function(id) {
let kw = this.findKeyword(this.keywords, id)
let kw2 = this.findKeyword(this.keywords2, id)
let p_id = undefined
p_id = kw?.parent ?? kw2.parent
if (p_id) {
let parent = this.findKeyword(this.keywords, p_id)
let parent2 = this.findKeyword(this.keywords2, p_id)
if (parent){
Vue.set(parent, 'numchild', parent.numchild - 1)
if (parent.show_children) {
let idx = parent.children.indexOf(parent.children.find(kw => kw.id === id))
Vue.delete(parent.children, idx)
}
}
if (parent2){
Vue.set(parent2, 'numchild', parent2.numchild - 1)
if (parent2.show_children) {
let idx = parent2.children.indexOf(parent2.children.find(kw => kw.id === id))
Vue.delete(parent2.children, idx)
}
}
}
this.keywords = this.keywords.filter(kw => kw.id != id)
this.keywords2 = this.keywords2.filter(kw => kw.id != id)
}
}
}
</script>
<style src="vue-multiselect/dist/vue-multiselect.min.css"></style>
<style>
</style>

View File

@ -1,10 +0,0 @@
import Vue from 'vue'
import App from './KeywordListView'
import i18n from '@/i18n'
Vue.config.productionTip = false
new Vue({
i18n,
render: h => h(App),
}).$mount('#app')

View File

@ -28,6 +28,11 @@
<template v-slot:upper-right>
<b-button v-if="i.recipe" v-b-tooltip.hover :title="i.recipe.name"
class=" btn fas fa-book-open p-0 border-0" variant="link" :href="i.recipe.url"/>
<!-- keywords can have icons - if it exists, display it -->
<b-button v-if="i.icon"
class=" btn p-0 border-0" variant="link">
{{i.icon}}
</b-button>
</template>
</generic-horizontal-card>
</template>
@ -98,11 +103,8 @@ export default {
methods: {
// this.genericAPI inherited from ApiMixin
resetList: function (e) {
if (e.column === 'left') {
this.items_left = []
} else if (e.column === 'right') {
this.items_right = []
}
this.items_right = []
this.items_left = []
},
startAction: function (e, param) {
let source = e?.source ?? {}
@ -205,10 +207,11 @@ export default {
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) => {
// place all new items at the top of the list - could sort instead
this.items_left = [result.data].concat(this.items_left)
// look for and destroy any existing cards to prevent duplicates in the GET case of get_or_create
// then place all new items at the top of the list - could sort instead
this.items_left = [result.data].concat(this.destroyCard(result?.data?.id, this.items_left))
// this creates a deep copy to make sure that columns stay independent
this.items_right = [{...result.data}].concat(this.items_right)
this.items_right = [{...result.data}].concat(this.destroyCard(result?.data?.id, this.items_right))
StandardToasts.makeStandardToast(StandardToasts.SUCCESS_CREATE)
}).catch((err) => {
console.log(err)
@ -230,14 +233,15 @@ export default {
this.clearState()
return
}
if (source_id === undefined || target_id === undefined) {
let item = this.findCard(source_id, this.items_left) || this.findCard(source_id, this.items_right)
if (source_id === undefined || target_id === undefined || item?.parent == target_id) {
this.makeToast(this.$t('Warning'), this.$t('Nothing to do'), 'warning')
this.clearState()
return
}
this.genericAPI(this.this_model, this.Actions.MOVE, {'source': source_id, 'target': target_id}).then((result) => {
if (target_id === 0) {
let item = this.findCard(source_id, this.items_left) || this.findCard(source_id, this.items_right)
this.items_left = [item].concat(this.destroyCard(source_id, this.items_left)) // order matters, destroy old card before adding it back in at root
this.items_right = [...[item]].concat(this.destroyCard(source_id, this.items_right)) // order matters, destroy old card before adding it back in at root
item.parent = null

View File

@ -172,6 +172,11 @@ export default {
resetSearch: function () {
this.search_right = ''
this.search_left = ''
this.right_page = 0
this.left_page = 0
this.right += 1
this.left += 1
this.$emit('reset')
},
infiniteHandler: function($state, col) {
let params = {

View File

@ -1,212 +0,0 @@
<template>
<div row>
<b-card no-body d-flex flex-column :class="{'border border-primary' : over, 'shake': isError}"
refs="keywordCard"
style="height: 10vh;" :style="{'cursor:grab' : draggable}"
@dragover.prevent
@dragenter.prevent
:draggable="draggable"
@dragstart="handleDragStart($event)"
@dragenter="handleDragEnter($event)"
@dragleave="handleDragLeave($event)"
@drop="handleDragDrop($event)">
<b-row no-gutters style="height:inherit;">
<b-col no-gutters md="3" style="height:inherit;">
<b-card-img-lazy style="object-fit: cover; height: 10vh;" :src="keyword_image" v-bind:alt="$t('Recipe_Image')"></b-card-img-lazy>
</b-col>
<b-col no-gutters md="9" 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">{{ keyword.name }}</h5>
<div class= "m-0 text-truncate">{{ keyword.description }}</div>
<div class="mt-auto mb-1 d-flex flex-row justify-content-end">
<div v-if="keyword.numchild !=0" class="mx-2 btn btn-link btn-sm"
style="z-index: 800;" v-on:click="$emit('item-action',{'action':'get-children','source':keyword})">
<div v-if="!keyword.expanded">{{keyword.numchild}} {{$t('Keywords')}}</div>
<div v-else>{{$t('Hide Keywords')}}</div>
</div>
<div class="mx-2 btn btn-link btn-sm" style="z-index: 800;"
v-on:click="$emit('item-action',{'action':'get-recipes','source':keyword})">
<div v-if="!keyword.show_recipes">{{keyword.numrecipe}} {{$t('Recipes')}}</div>
<div v-else>{{$t('Hide Recipes')}}</div>
</div>
</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">
<generic-context-menu class="p-0"
:show_merge="true"
:show_move="true"
@item-action="$emit('item-action', {'action': $event, 'source': keyword})">
</generic-context-menu>
</div>
</b-row>
</b-card>
<!-- recursively add child keywords -->
<div class="row" v-if="keyword.expanded">
<div class="col-md-11 offset-md-1">
<keyword-card v-for="child in keyword.children"
:keyword="child"
v-bind:key="child.id"
draggable="true"
@item-action="$emit('item-action', $event)">
</keyword-card>
</div>
</div>
<!-- conditionally view recipes -->
<div class="row" v-if="keyword.show_recipes">
<div class="col-md-11 offset-md-1">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));grid-gap: 1rem;">
<recipe-card v-for="r in keyword.recipes"
v-bind:key="r.id"
:recipe="r">
</recipe-card>
</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:999; cursor:pointer">
<b-list-group-item action v-on:click="$emit('item-action',{'action': 'move', 'target': keyword, 'source': source}); closeMenu()">
<i class="fas fa-expand-arrows-alt fa-fw"></i> {{$t('Move')}}: {{$t('move_confirmation', {'child': source.name,'parent':keyword.name})}}
</b-list-group-item>
<b-list-group-item action v-on:click="$emit('item-action',{'action': 'merge', 'target': keyword, 'source': source}); closeMenu()">
<i class="fas fa-compress-arrows-alt fa-fw"></i> {{$t('Merge')}}: {{ $t('merge_confirmation', {'source': source.name,'target':keyword.name}) }}
</b-list-group-item>
<b-list-group-item action v-on:click="closeMenu()">
<i class="fas fa-times fa-fw"></i> {{$t('Cancel')}}
</b-list-group-item>
</b-list-group>
</div>
</template>
<script>
import GenericContextMenu from "@/components/GenericContextMenu";
import RecipeCard from "@/components/RecipeCard";
import { mixin as clickaway } from 'vue-clickaway';
import { createPopper } from '@popperjs/core';
export default {
name: "KeywordCard",
components: { GenericContextMenu, RecipeCard },
mixins: [clickaway],
props: {
keyword: Object,
draggable: {type: Boolean, default: false}
},
data() {
return {
keyword_image: '',
over: false,
show_menu: false,
dragMenu: undefined,
isError: false,
source: {},
target: {}
}
},
mounted() {
if (this.keyword == null || this.keyword.image == null) {
this.keyword_image = window.IMAGE_PLACEHOLDER
} else {
this.keyword_image = this.keyword.image
}
this.dragMenu = this.$refs.tooltip
},
methods: {
handleDragStart: function(e) {
this.isError = false
e.dataTransfer.setData('source', JSON.stringify(this.keyword))
},
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.keyword.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.keyword, '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
}
}
}
</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;
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-4px, 0, 0);
}
40%,
60% {
transform: translate3d(4px, 0, 0);
}
}
</style>

View File

@ -0,0 +1,71 @@
<template>
<div>
<b-form-group
v-bind:label="label"
class="mb-3">
<twemoji-textarea
:ref="'_edit_' + id"
:initialContent="value"
:emojiData="emojiDataAll"
:emojiGroups="emojiGroups"
triggerType="hover"
recentEmojisFeat="true"
recentEmojisStorage="local"
@contentChanged="setIcon"
/>
</b-form-group>
</div>
</template>
<script>
import {TwemojiTextarea} from '@kevinfaguiar/vue-twemoji-picker';
// TODO add localization
import EmojiAllData from '@kevinfaguiar/vue-twemoji-picker/emoji-data/en/emoji-all-groups.json';
import EmojiGroups from '@kevinfaguiar/vue-twemoji-picker/emoji-data/emoji-groups.json';
export default {
name: 'EmojiInput',
components: {TwemojiTextarea},
props: {
field: {type: String, default: 'You Forgot To Set Field Name'},
label: {type: String, default: ''},
value: {type: String, default: ''},
},
data() {
return {
new_value: undefined,
id: null
}
},
computed: {
// modelName() {
// return this?.model?.name ?? this.$t('Search')
// },
emojiDataAll() {
return EmojiAllData;
},
emojiGroups() {
return EmojiGroups;
}
},
watch: {
'new_value': function () {
this.$root.$emit('change', this.field, this.new_value ?? null)
},
},
mounted() {
this.id = this._uid
},
methods: {
prepareEmoji: function() {
this.$refs['_edit_' + this.id].addText(this.this_item.icon || '');
this.$refs['_edit_' + this.id].blur()
document.getElementById('btn-emoji-default').disabled = true;
},
setIcon: function(icon) {
this.new_value = icon
},
}
}
</script>

View File

@ -12,7 +12,6 @@
:model="listModel(f.list)"
:sticky_options="f.sticky_options || undefined"
@change="storeValue"/> <!-- TODO add ability to create new items associated with lookup -->
<!-- TODO: add emoji field -->
<!-- TODO: add multi-selection input list -->
<checkbox-input v-if="f.type=='checkbox'"
:label="f.label"
@ -23,6 +22,11 @@
:value="f.value"
:field="f.field"
:placeholder="f.placeholder"/>
<emoji-input v-if="f.type=='emoji'"
:label="f.label"
:value="f.value"
:field="f.field"
@change="storeValue"/>
</div>
<template v-slot:modal-footer>
@ -43,10 +47,11 @@ import {Models} from "@/utils/models";
import CheckboxInput from "@/components/Modals/CheckboxInput";
import LookupInput from "@/components/Modals/LookupInput";
import TextInput from "@/components/Modals/TextInput";
import EmojiInput from "@/components/Modals/EmojiInput";
export default {
name: 'GenericModalForm',
components: {CheckboxInput, LookupInput, TextInput},
components: {CheckboxInput, LookupInput, TextInput, EmojiInput},
props: {
model: {required: true, type: Object, default: function() {}},
action: {required: true, type: Object, default: function() {}},

View File

@ -47,10 +47,5 @@ export default {
this.$root.$emit('change', this.field, this.new_value?.id ?? null)
},
},
methods: {
Button: function(e) {
this.$bvModal.show('modal')
}
}
}
</script>

View File

@ -121,5 +121,6 @@
"Name": "Name",
"Description": "Description",
"Recipe": "Recipe",
"tree_root": "Root of Tree"
"tree_root": "Root of Tree",
"Icon": "Icon"
}

View File

@ -108,7 +108,33 @@ export class Models {
static KEYWORD = {
'name': i18n.t('Keyword'), // *OPTIONAL: parameters will be built model -> model_type -> default
'apiName': 'Keyword',
'model_type': this.TREE
'model_type': this.TREE,
'create': {
// if not defined partialUpdate will use the same parameters, prepending 'id'
'params': [['name', 'description', 'icon']],
'form': {
'name': {
'form_field': true,
'type': 'text',
'field': 'name',
'label': i18n.t('Name'),
'placeholder': ''
},
'description': {
'form_field': true,
'type': 'text',
'field': 'description',
'label': i18n.t('Description'),
'placeholder': ''
},
'icon': {
'form_field': true,
'type': 'emoji',
'field': 'icon',
'label': i18n.t('Icon')
},
}
},
}
static UNIT = {}
static RECIPE = {}

View File

@ -25,10 +25,6 @@ const pages = {
entry: './src/apps/UserFileView/main.js',
chunks: ['chunk-vendors']
},
'keyword_list_view': {
entry: './src/apps/KeywordListView/main.js',
chunks: ['chunk-vendors']
},
'model_list_view': {
entry: './src/apps/ModelListView/main.js',
chunks: ['chunk-vendors']
@ -85,7 +81,7 @@ module.exports = {
},
},
// TODO make this conditional on .env DEBUG = FALSE
config.optimization.minimize(false)
config.optimization.minimize(true)
);
//TODO somehow remov them as they are also added to the manifest config of the service worker

View File

@ -1 +1,119 @@
{"status":"done","chunks":{"recipe_search_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_search_view.js"],"recipe_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/recipe_view.js"],"offline_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/offline_view.js"],"import_response_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/import_response_view.js"],"supermarket_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/supermarket_view.js"],"user_file_view":["css/chunk-vendors.css","js/chunk-vendors.js","js/user_file_view.js"],"keyword_list_view":["css/chunk-vendors.css","js/chunk-vendors.js","css/keyword_list_view.css","js/keyword_list_view.js"],"model_list_view":["css/chunk-vendors.css","js/chunk-vendors.js","css/model_list_view.css","js/model_list_view.js"]},"assets":{"../../templates/sw.js":{"name":"../../templates/sw.js","path":"../../templates/sw.js"},"css/chunk-vendors.css":{"name":"css/chunk-vendors.css","path":"css/chunk-vendors.css"},"js/chunk-vendors.js":{"name":"js/chunk-vendors.js","path":"js/chunk-vendors.js"},"js/import_response_view.js":{"name":"js/import_response_view.js","path":"js/import_response_view.js"},"css/keyword_list_view.css":{"name":"css/keyword_list_view.css","path":"css/keyword_list_view.css"},"js/keyword_list_view.js":{"name":"js/keyword_list_view.js","path":"js/keyword_list_view.js"},"css/model_list_view.css":{"name":"css/model_list_view.css","path":"css/model_list_view.css"},"js/model_list_view.js":{"name":"js/model_list_view.js","path":"js/model_list_view.js"},"js/offline_view.js":{"name":"js/offline_view.js","path":"js/offline_view.js"},"js/recipe_search_view.js":{"name":"js/recipe_search_view.js","path":"js/recipe_search_view.js"},"js/recipe_view.js":{"name":"js/recipe_view.js","path":"js/recipe_view.js"},"js/supermarket_view.js":{"name":"js/supermarket_view.js","path":"js/supermarket_view.js"},"js/user_file_view.js":{"name":"js/user_file_view.js","path":"js/user_file_view.js"},"recipe_search_view.html":{"name":"recipe_search_view.html","path":"recipe_search_view.html"},"recipe_view.html":{"name":"recipe_view.html","path":"recipe_view.html"},"offline_view.html":{"name":"offline_view.html","path":"offline_view.html"},"import_response_view.html":{"name":"import_response_view.html","path":"import_response_view.html"},"supermarket_view.html":{"name":"supermarket_view.html","path":"supermarket_view.html"},"user_file_view.html":{"name":"user_file_view.html","path":"user_file_view.html"},"keyword_list_view.html":{"name":"keyword_list_view.html","path":"keyword_list_view.html"},"model_list_view.html":{"name":"model_list_view.html","path":"model_list_view.html"},"manifest.json":{"name":"manifest.json","path":"manifest.json"}}}
{
"status": "done",
"assets": {
"../../templates/sw.js": {
"name": "../../templates/sw.js",
"path": "../../templates/sw.js"
},
"css/chunk-vendors.css": {
"name": "css/chunk-vendors.css",
"path": "css/chunk-vendors.css"
},
"js/chunk-vendors.js": {
"name": "js/chunk-vendors.js",
"path": "js/chunk-vendors.js"
},
"js/import_response_view.js": {
"name": "js/import_response_view.js",
"path": "js/import_response_view.js"
},
"css/model_list_view.css": {
"name": "css/model_list_view.css",
"path": "css/model_list_view.css"
},
"js/model_list_view.js": {
"name": "js/model_list_view.js",
"path": "js/model_list_view.js"
},
"js/offline_view.js": {
"name": "js/offline_view.js",
"path": "js/offline_view.js"
},
"js/recipe_search_view.js": {
"name": "js/recipe_search_view.js",
"path": "js/recipe_search_view.js"
},
"js/recipe_view.js": {
"name": "js/recipe_view.js",
"path": "js/recipe_view.js"
},
"js/supermarket_view.js": {
"name": "js/supermarket_view.js",
"path": "js/supermarket_view.js"
},
"js/user_file_view.js": {
"name": "js/user_file_view.js",
"path": "js/user_file_view.js"
},
"recipe_search_view.html": {
"name": "recipe_search_view.html",
"path": "recipe_search_view.html"
},
"recipe_view.html": {
"name": "recipe_view.html",
"path": "recipe_view.html"
},
"offline_view.html": {
"name": "offline_view.html",
"path": "offline_view.html"
},
"import_response_view.html": {
"name": "import_response_view.html",
"path": "import_response_view.html"
},
"supermarket_view.html": {
"name": "supermarket_view.html",
"path": "supermarket_view.html"
},
"user_file_view.html": {
"name": "user_file_view.html",
"path": "user_file_view.html"
},
"model_list_view.html": {
"name": "model_list_view.html",
"path": "model_list_view.html"
},
"manifest.json": {
"name": "manifest.json",
"path": "manifest.json"
}
},
"chunks": {
"recipe_search_view": [
"css/chunk-vendors.css",
"js/chunk-vendors.js",
"js/recipe_search_view.js"
],
"recipe_view": [
"css/chunk-vendors.css",
"js/chunk-vendors.js",
"js/recipe_view.js"
],
"offline_view": [
"css/chunk-vendors.css",
"js/chunk-vendors.js",
"js/offline_view.js"
],
"import_response_view": [
"css/chunk-vendors.css",
"js/chunk-vendors.js",
"js/import_response_view.js"
],
"supermarket_view": [
"css/chunk-vendors.css",
"js/chunk-vendors.js",
"js/supermarket_view.js"
],
"user_file_view": [
"css/chunk-vendors.css",
"js/chunk-vendors.js",
"js/user_file_view.js"
],
"model_list_view": [
"css/chunk-vendors.css",
"js/chunk-vendors.js",
"css/model_list_view.css",
"js/model_list_view.js"
]
}
}