feature/new-recipe-features (#360)

* unify button styles

* fix drag on mobile

* recipe instructions section

* add carbs

* refactor component location

* asset start

* consolidate view/edit components

* asset api

* base dialog event

* Remove 'content'

* remove console.log

* add slug prop

* remove console.log

* recipe assets first pass

* add recipeSettings model

* fix hide/show when no tags/categories

* fix typo

Co-authored-by: hay-kot <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-04-28 18:59:37 -08:00 committed by GitHub
parent 9abb6f10fd
commit 04255e285f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 246 additions and 749 deletions

1
.gitignore vendored
View File

@ -159,3 +159,4 @@ scratch.py
dev/data/backups/dev_sample_data*.zip
dev/data/backups/dev_sample_data*.zip
!dev/data/backups/test*.zip
dev/data/recipes/*

View File

@ -16,6 +16,7 @@ const recipeURLs = {
delete: slug => prefix + slug,
recipeImage: slug => `${prefix}${slug}/image`,
updateImage: slug => `${prefix}${slug}/image`,
createAsset: slug => `${prefix}${slug}/asset`,
};
export const recipeAPI = {
@ -60,12 +61,23 @@ export const recipeAPI = {
return response;
},
async createAsset(recipeSlug, fileObject, name, icon) {
const fd = new FormData();
fd.append("file", fileObject);
fd.append("extension", fileObject.name.split(".").pop());
fd.append("name", name);
fd.append("icon", icon);
let response = apiReq.post(recipeURLs.createAsset(recipeSlug), fd);
return response;
},
async updateImagebyURL(slug, url) {
const response = apiReq.post(recipeURLs.updateImage(slug), { url: url });
return response;
},
async update(data) {
console.log(data)
let response = await apiReq.put(recipeURLs.update(data.slug), data);
store.dispatch("patchRecipe", response.data);
return response.data.slug; // ! Temporary until I rewrite to refresh page without additional request

View File

@ -1,64 +0,0 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="600">
<template v-slot:activator="{ on, attrs }">
<v-btn
text
color="secondary lighten-2"
dark
v-bind="attrs"
v-on="on"
@click="inputText = ''"
>
{{$t('new-recipe.bulk-add')}}
</v-btn>
</template>
<v-card>
<v-card-title class="headline"> {{$t('new-recipe.bulk-add')}} </v-card-title>
<v-card-text>
<p>
{{$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')}}
</p>
<v-textarea v-model="inputText"> </v-textarea>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="success" text @click="save"> {{$t('general.save')}} </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
data() {
return {
dialog: false,
inputText: "",
};
},
methods: {
splitText() {
let split = this.inputText.split("\n");
split.forEach((element, index) => {
if ((element === "\n") | (element == false)) {
split.splice(index, 1);
}
});
return split;
},
save() {
this.$emit("bulk-data", this.splitText());
this.dialog = false;
},
},
};
</script>

View File

@ -1,104 +0,0 @@
<template>
<div class="text-center">
<v-dialog v-model="dialog" width="700">
<template v-slot:activator="{ on, attrs }">
<v-btn color="accent" dark v-bind="attrs" v-on="on"> {{ $t("recipe.api-extras") }} </v-btn>
</template>
<v-card>
<v-card-title> {{ $t("recipe.api-extras") }} </v-card-title>
<v-card-text :key="formKey">
<v-row
align="center"
v-for="(value, key, index) in extras"
:key="index"
>
<v-col cols="12" sm="1">
<v-btn
fab
text
x-small
color="white"
elevation="0"
@click="removeExtra(key)"
>
<v-icon color="error">mdi-delete</v-icon>
</v-btn>
</v-col>
<v-col cols="12" md="3" sm="6">
<v-text-field
:label="$t('recipe.object-key')"
:value="key"
@input="updateKey(index)"
>
</v-text-field>
</v-col>
<v-col cols="12" md="8" sm="6">
<v-text-field :label="$t('recipe.object-value')" v-model="extras[key]">
</v-text-field>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<v-form ref="addKey">
<v-text-field
:label="$t('recipe.new-key-name')"
v-model="newKeyName"
class="pr-4"
:rules="[rules.required, rules.whiteSpace]"
></v-text-field>
</v-form>
<v-btn color="info" text @click="append"> {{ $t("recipe.add-key") }} </v-btn>
<v-spacer></v-spacer>
<v-btn color="success" text @click="save"> {{ $t("general.save") }} </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
props: {
extras: Object,
},
data() {
return {
newKeyName: null,
dialog: false,
formKey: 1,
rules: {
required: (v) => !!v || this.$i18n.t("recipe.key-name-required"),
whiteSpace: (v) =>
!v || v.split(" ").length <= 1 || this.$i18n.t("recipe.no-white-space-allowed"),
},
};
},
methods: {
save() {
this.$emit("save", this.extras);
this.dialog = false;
},
append() {
if (this.$refs.addKey.validate()) {
this.extras[this.newKeyName] = "value";
this.formKey += 1;
}
},
removeExtra(key) {
delete this.extras[key];
this.formKey += 1;
},
},
};
</script>
<style>
</style>

View File

@ -1,75 +0,0 @@
<template>
<div class="text-center">
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
<template v-slot:activator="{ on, attrs }">
<v-btn color="accent" dark v-bind="attrs" v-on="on">
{{$t('recipe.image')}}
</v-btn>
</template>
<v-card width="400">
<v-card-title class="headline flex mb-0">
<div>
{{$t('recipe.recipe-image')}}
</div>
<TheUploadBtn
class="ml-auto"
url="none"
file-name="image"
:text-btn="false"
@uploaded="uploadImage"
:post="false"
/>
</v-card-title>
<v-card-text class="mt-n5">
<div>
<v-text-field :label="$t('general.url')" class="pt-5" clearable v-model="url">
<template v-slot:append-outer>
<v-btn
class="ml-2"
color="primary"
@click="getImageFromURL"
:loading="loading"
>
{{$t('general.get')}}
</v-btn>
</template>
</v-text-field>
</div>
</v-card-text>
</v-card>
</v-menu>
</div>
</template>
<script>
const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload";
import TheUploadBtn from "@/components/UI/Buttons/TheUploadBtn";
import { api } from "@/api";
export default {
components: {
TheUploadBtn,
},
props: {
slug: String,
},
data: () => ({
url: "",
loading: false,
}),
methods: {
uploadImage(fileObject) {
this.$emit(UPLOAD_EVENT, fileObject);
},
async getImageFromURL() {
this.loading = true;
const response = await api.recipes.updateImagebyURL(this.slug, this.url);
if (response) this.$emit(REFRESH_EVENT);
this.loading = false;
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -1,81 +0,0 @@
<template>
<div v-if="valueNotNull || edit">
<h2 class="my-4">{{$t('recipe.nutrition')}}</h2>
<div v-if="edit">
<div v-for="(item, key, index) in value" :key="index">
<v-text-field
dense
:value="value[key]"
:label="labels[key].label"
:suffix="labels[key].suffix"
type="number"
autocomplete="off"
@input="updateValue(key, $event)"
></v-text-field>
</div>
</div>
<div v-if="showViewer">
<v-list dense>
<v-list-item-group color="primary">
<v-list-item v-for="(item, key, index) in labels" :key="index">
<v-list-item-content>
<v-list-item-title class="pl-4 text-subtitle-1 flex row ">
<div>{{ item.label }}</div>
<div class="ml-auto mr-1">{{ value[key] }}</div>
<div>{{ item.suffix }}</div>
</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list-item-group>
</v-list>
</div>
</div>
</template>
<script>
export default {
props: {
value: {},
edit: {
type: Boolean,
default: true,
},
},
data() {
return {
labels: {
calories: {
label: this.$t('recipe.calories'),
suffix:this.$t('recipe.calories-suffix'),
},
fatContent: { label: this.$t('recipe.fat-content'), suffix: this.$t('recipe.grams') },
fiberContent: { label: this.$t('recipe.fiber-content'), suffix: this.$t('recipe.grams') },
proteinContent: { label: this.$t('recipe.protein-content'), suffix: this.$t('recipe.grams') },
sodiumContent: { label: this.$t('recipe.sodium-content'), suffix: this.$t('recipe.milligrams') },
sugarContent: { label: this.$t('recipe.sugar-content'), suffix: this.$t('recipe.grams') },
},
};
},
computed: {
showViewer() {
return !this.edit && this.valueNotNull;
},
valueNotNull() {
for (const property in this.value) {
const valueProperty = this.value[property];
if (valueProperty && valueProperty !== "") return true;
}
return false;
},
},
methods: {
updateValue(key, value) {
this.$emit("input", { ...this.value, [key]: value });
},
},
};
</script>
<style lang="scss" scoped>
</style>

View File

@ -3,7 +3,7 @@
<v-card-text>
<v-row dense>
<ImageUploadBtn
class="mt-2"
class="my-1"
@upload="uploadImage"
:slug="value.slug"
@refresh="$emit('upload')"
@ -64,50 +64,7 @@
</v-row>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<draggable
v-model="value.recipeIngredient"
@start="drag = true"
@end="drag = false"
>
<transition-group
type="transition"
:name="!drag ? 'flip-list' : null"
>
<div
v-for="(ingredient, index) in value.recipeIngredient"
:key="generateKey('ingredient', index)"
>
<v-row align="center">
<v-textarea
class="mr-2"
:label="$t('recipe.ingredient')"
v-model="value.recipeIngredient[index]"
append-outer-icon="mdi-menu"
mdi-move-resize
auto-grow
solo
dense
rows="1"
>
<v-icon
class="mr-n1"
slot="prepend"
color="error"
@click="removeByIndex(value.recipeIngredient, index)"
>
mdi-delete
</v-icon>
</v-textarea>
</v-row>
</div>
</transition-group>
</draggable>
<v-btn color="secondary" fab dark small @click="addIngredient">
<v-icon>mdi-plus</v-icon>
</v-btn>
<BulkAdd @bulk-data="addIngredient" />
<Ingredients :edit="true" v-model="value.recipeIngredient" />
<h2 class="mt-6">{{ $t("recipe.categories") }}</h2>
<CategoryTagSelector
@ -125,87 +82,23 @@
:tag-selector="true"
:show-label="false"
/>
<h2 class="my-4">{{ $t("recipe.notes") }}</h2>
<v-card
class="mt-1"
v-for="(note, index) in value.notes"
:key="generateKey('note', index)"
>
<v-card-text>
<v-row align="center">
<v-btn
fab
x-small
color="white"
class="mr-2"
elevation="0"
@click="removeByIndex(value.notes, index)"
>
<v-icon color="error">mdi-delete</v-icon>
</v-btn>
<v-text-field
:label="$t('recipe.title')"
v-model="value.notes[index]['title']"
></v-text-field>
</v-row>
<v-textarea
auto-grow
:label="$t('recipe.note')"
v-model="value.notes[index]['text']"
>
</v-textarea>
</v-card-text>
</v-card>
<v-btn class="mt-1" color="secondary" fab dark small @click="addNote">
<v-icon>mdi-plus</v-icon>
</v-btn>
<NutritionEditor v-model="value.nutrition" :edit="true" />
<Nutrition v-model="value.nutrition" :edit="true" />
<Assets v-model="value.assets" :edit="true" :slug="value.slug" />
<ExtrasEditor :extras="value.extras" @save="saveExtras" />
</v-col>
<v-divider class="my-divider" :vertical="true"></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
<div v-for="(step, index) in value.recipeInstructions" :key="index">
<v-hover v-slot="{ hover }">
<v-card
class="ma-1"
:class="[{ 'on-hover': hover }]"
:elevation="hover ? 12 : 2"
>
<v-card-title>
<v-btn
fab
x-small
color="white"
class="mr-2"
elevation="0"
@click="removeByIndex(value.recipeInstructions, index)"
>
<v-icon size="24" color="error">mdi-delete</v-icon>
</v-btn>
{{ $t("recipe.step-index", { step: index + 1 }) }}
</v-card-title>
<v-card-text>
<v-textarea
auto-grow
dense
v-model="value.recipeInstructions[index]['text']"
:key="generateKey('instructions', index)"
rows="4"
>
</v-textarea>
</v-card-text>
</v-card>
</v-hover>
<Instructions v-model="value.recipeInstructions" :edit="true" />
<div class="d-flex row justify-end mt-2">
<BulkAdd @bulk-data="appendSteps" class="mr-2" />
<v-btn color="secondary" dark @click="addStep" class="mr-4">
<v-icon>mdi-plus</v-icon>
</v-btn>
</div>
<v-btn color="secondary" fab dark small @click="addStep">
<v-icon>mdi-plus</v-icon>
</v-btn>
<BulkAdd @bulk-data="appendSteps" />
<Notes :edit="true" v-model="value.notes" />
<v-text-field
v-model="value.orgURL"
class="mt-10"
@ -219,22 +112,27 @@
<script>
const UPLOAD_EVENT = "upload";
import draggable from "vuedraggable";
import utils from "@/utils";
import BulkAdd from "./BulkAdd";
import ExtrasEditor from "./ExtrasEditor";
import BulkAdd from "@/components/Recipe/Parts/Helpers/BulkAdd";
import ExtrasEditor from "@/components/Recipe/Parts/Helpers/ExtrasEditor";
import CategoryTagSelector from "@/components/FormHelpers/CategoryTagSelector";
import NutritionEditor from "./NutritionEditor";
import ImageUploadBtn from "./ImageUploadBtn.vue";
import ImageUploadBtn from "@/components/Recipe/Parts/Helpers/ImageUploadBtn";
import { validators } from "@/mixins/validators";
import Nutrition from "@/components/Recipe/Parts/Nutrition";
import Instructions from "@/components/Recipe/Parts/Instructions";
import Ingredients from "@/components/Recipe/Parts/Ingredients";
import Assets from "@/components/Recipe/Parts/Assets.vue";
import Notes from "@/components/Recipe/Parts/Notes.vue";
export default {
components: {
BulkAdd,
ExtrasEditor,
draggable,
CategoryTagSelector,
NutritionEditor,
Nutrition,
ImageUploadBtn,
Instructions,
Ingredients,
Assets,
Notes,
},
props: {
value: Object,
@ -242,7 +140,6 @@ export default {
mixins: [validators],
data() {
return {
drag: false,
fileObject: null,
};
},
@ -250,30 +147,6 @@ export default {
uploadImage(fileObject) {
this.$emit(UPLOAD_EVENT, fileObject);
},
toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
const index = this.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
this.disabledSteps.splice(index, 1);
}
} else {
this.disabledSteps.push(stepIndex);
}
},
isDisabled(stepIndex) {
return this.disabledSteps.includes(stepIndex) ? "disabled-card" : null;
},
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
addIngredient(ingredients = null) {
if (ingredients) {
this.value.recipeIngredient.push(...ingredients);
} else {
this.value.recipeIngredient.push("");
}
},
appendSteps(steps) {
this.value.recipeInstructions.push(
...steps.map(x => ({
@ -284,15 +157,9 @@ export default {
addStep() {
this.value.recipeInstructions.push({ text: "" });
},
addNote() {
this.value.notes.push({ text: "" });
},
saveExtras(extras) {
this.value.extras = extras;
},
removeByIndex(list, index) {
list.splice(index, 1);
},
validateRecipe() {
return this.$refs.form.validate();
},

View File

@ -1,62 +0,0 @@
<template>
<div>
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
<v-list-item
dense
v-for="(ingredient, index) in ingredients"
:key="generateKey('ingredient', index)"
@click="toggleChecked(index)"
>
<v-checkbox
hide-details
:value="checked[index]"
class="pt-0 my-auto py-auto"
color="secondary"
>
</v-checkbox>
<v-list-item-content>
<vue-markdown
class="ma-0 pa-0 text-subtitle-1 dense-markdown"
:source="ingredient"
>
</vue-markdown>
</v-list-item-content>
</v-list-item>
</div>
</template>
<script>
import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils";
export default {
components: {
VueMarkdown,
},
props: {
ingredients: Array,
},
data() {
return {
checked: [],
};
},
mounted() {
this.checked = this.ingredients.map(() => false);
},
methods: {
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
toggleChecked(index) {
this.$set(this.checked, index, !this.checked[index]);
},
},
};
</script>
<style >
.dense-markdown p {
margin: auto !important;
}
</style>

View File

@ -1,36 +0,0 @@
<template>
<div>
<h2 v-if="notes[0]" class="my-4">{{ $t("recipe.notes") }}</h2>
<v-card
class="mt-1"
v-for="(note, index) in notes"
:key="generateKey('note', index)"
>
<v-card-title> {{ note.title }}</v-card-title>
<v-card-text>
<vue-markdown :source="note.text"> </vue-markdown>
</v-card-text>
</v-card>
</div>
</template>
<script>
import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils";
export default {
props: {
notes: Array,
},
components: {
VueMarkdown,
},
methods: {
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
},
};
</script>
<style>
</style>

View File

@ -1,67 +0,0 @@
<template>
<div>
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
<v-hover
v-for="(step, index) in steps"
:key="generateKey('step', index)"
v-slot="{ hover }"
>
<v-card
class="ma-1"
:class="[{ 'on-hover': hover }, isDisabled(index)]"
:elevation="hover ? 12 : 2"
@click="toggleDisabled(index)"
>
<v-card-title>{{
$t("recipe.step-index", { step: index + 1 })
}}</v-card-title>
<v-card-text>
<vue-markdown :source="step.text"> </vue-markdown>
</v-card-text>
</v-card>
</v-hover>
</div>
</template>
<script>
import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils";
export default {
props: {
steps: Array,
},
components: {
VueMarkdown,
},
data() {
return {
disabledSteps: [],
};
},
methods: {
toggleDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
let index = this.disabledSteps.indexOf(stepIndex);
if (index !== -1) {
this.disabledSteps.splice(index, 1);
}
} else {
this.disabledSteps.push(stepIndex);
}
},
isDisabled(stepIndex) {
if (this.disabledSteps.includes(stepIndex)) {
return "disabled-card";
} else {
return;
}
},
generateKey(item, index) {
return utils.generateUniqueKey(item, index);
},
},
};
</script>
<style>
</style>

View File

@ -31,16 +31,30 @@
</v-row>
<v-row>
<v-col cols="12" sm="12" md="4" lg="4">
<Ingredients :ingredients="ingredients" />
<Ingredients :value="ingredients" :edit="false" />
<div v-if="medium">
<RecipeChips :title="$t('recipe.categories')" :items="categories" />
<RecipeChips
:title="$t('recipe.tags')"
:items="tags"
:isCategory="false"
/>
<Notes :notes="notes" />
<NutritionEditor :value="nutrition" :edit="false" />
<v-card class="mt-2" v-if="categories.length > 0"
>
<v-card-title class="py-2">
{{ $t("recipe.categories") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="categories" />
</v-card-text>
</v-card>
<v-card class="mt-2" v-if="tags.length > 0">
<v-card-title class="py-2">
{{ $t("recipe.tags") }}
</v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
<RecipeChips :items="tags" :isCategory="false" />
</v-card-text>
</v-card>
<Nutrition :value="nutrition" :edit="false" />
<Assets :value="assets" :edit="false" :slug="slug" />
</div>
</v-col>
<v-divider
@ -50,14 +64,15 @@
></v-divider>
<v-col cols="12" sm="12" md="8" lg="8">
<Steps :steps="instructions" />
<Instructions :value="instructions" :edit="false" />
<Notes :value="notes" :edit="false" />
</v-col>
</v-row>
<div v-if="!medium">
<RecipeChips :title="$t('recipe.categories')" :items="categories" />
<RecipeChips :title="$t('recipe.tags')" :items="tags" />
<Notes :notes="notes" />
<NutritionEditor :value="nutrition" :edit="false" />
<Nutrition :value="nutrition" :edit="false" />
<Assets :value="assets" :edit="false" :slug="slug" />
</div>
<v-row class="mt-2 mb-1">
<v-col></v-col>
@ -82,24 +97,27 @@
</template>
<script>
import NutritionEditor from "@/components/Recipe/RecipeEditor/NutritionEditor";
import Nutrition from "@/components/Recipe/Parts/Nutrition";
import VueMarkdown from "@adapttive/vue-markdown";
import utils from "@/utils";
import RecipeChips from "./RecipeChips";
import Steps from "./Steps";
import Notes from "./Notes";
import Ingredients from "./Ingredients";
import Notes from "@/components/Recipe/Parts/Notes";
import Ingredients from "@/components/Recipe/Parts/Ingredients";
import Instructions from "@/components/Recipe/Parts/Instructions.vue";
import Assets from "../Parts/Assets.vue";
export default {
components: {
VueMarkdown,
RecipeChips,
Steps,
Notes,
Ingredients,
NutritionEditor,
Nutrition,
Instructions,
Assets,
},
props: {
name: String,
slug: String,
description: String,
ingredients: Array,
instructions: Array,
@ -110,6 +128,7 @@ export default {
yields: String,
orgURL: String,
nutrition: Object,
assets: Array,
},
data() {
return {

View File

@ -1,5 +1,6 @@
<template>
<div>
<slot name="open" v-bind="{ open }"> </slot>
<v-dialog
v-model="dialog"
:width="modalWidth + 'px'"
@ -25,7 +26,7 @@
Cancel
</v-btn>
<v-spacer></v-spacer>
<v-btn color="success" @click="$emit('submit')">
<v-btn color="success" @click="submitEvent">
Submit
</v-btn>
</slot>
@ -65,6 +66,10 @@ export default {
};
},
methods: {
submitEvent() {
this.$emit("submit");
this.close();
},
open() {
this.dialog = true;
},

View File

@ -105,6 +105,7 @@
"recent": "Recent"
},
"recipe": {
"assets": "Assets",
"add-key": "Add Key",
"api-extras": "API Extras",
"calories": "Calories",
@ -113,8 +114,9 @@
"delete-confirmation": "Are you sure you want to delete this recipe?",
"delete-recipe": "Delete Recipe",
"description": "Description",
"fat-content": "Fat Content",
"fiber-content": "Fiber Content",
"fat-content": "Fat",
"fiber-content": "Fiber",
"carbohydrate-content": "Carbohydrate",
"grams": "grams",
"image": "Image",
"ingredient": "Ingredient",
@ -132,13 +134,13 @@
"original-url": "Original URL",
"perform-time": "Cook Time",
"prep-time": "Prep Time",
"protein-content": "Protein Content",
"protein-content": "Protein",
"recipe-image": "Recipe Image",
"recipe-name": "Recipe Name",
"servings": "Servings",
"sodium-content": "Sodium Content",
"sodium-content": "Sodium",
"step-index": "Step: {step}",
"sugar-content": "Sugar Content",
"sugar-content": "Sugar",
"tags": "Tags",
"title": "Title",
"total-time": "Total Time",

View File

@ -68,7 +68,6 @@ export default {
},
async openDialog() {
this.$refs.deleteDialog.open();
console.log(this.isTags);
if (this.isTags) {
this.deleteList = await api.tags.getEmpty();
} else {

View File

@ -51,6 +51,8 @@
:yields="recipeDetails.recipeYield"
:orgURL="recipeDetails.orgURL"
:nutrition="recipeDetails.nutrition"
:assets="recipeDetails.assets"
:slug="recipeDetails.slug"
/>
<VJsoneditor
@error="logError()"

View File

@ -36,7 +36,6 @@ const actions = {
async requestRecentRecipes() {
const payload = await api.recipes.allSummary(0, 30);
payload.sort((a, b) => (a.dateAdded > b.dateAdded ? -1 : 1));
console.log(payload);
const hash = Object.fromEntries(payload.map(e => [e.id, e]));
this.commit("setRecentRecipes", hash);
},
@ -44,7 +43,6 @@ const actions = {
const all = getters.getAllRecipes;
const payload = await api.recipes.allSummary(all.length, 9999);
const hash = Object.fromEntries([...all, ...payload].map(e => [e.id, e]));
console.log(hash);
this.commit("setAllRecipes", hash);
},

View File

@ -8,7 +8,7 @@ from mealie.core.config import APP_VERSION, settings
from mealie.routes import backup_routes, debug_routes, migration_routes, theme_routes, utility_routes
from mealie.routes.groups import groups
from mealie.routes.mealplans import mealplans
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_crud_routes, tag_routes
from mealie.routes.recipe import all_recipe_routes, category_routes, recipe_assets, recipe_crud_routes, tag_routes
from mealie.routes.site_settings import all_settings
from mealie.routes.users import users
@ -37,6 +37,7 @@ def api_routers():
app.include_router(category_routes.router)
app.include_router(tag_routes.router)
app.include_router(recipe_crud_routes.router)
app.include_router(recipe_assets.router)
# Meal Routes
app.include_router(mealplans.router)
# Settings Routes

View File

@ -0,0 +1,22 @@
import sqlalchemy as sa
from mealie.db.models.model_base import SqlAlchemyBase
class RecipeAsset(SqlAlchemyBase):
__tablename__ = "recipe_assets"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.String, sa.ForeignKey("recipes.id"))
name = sa.Column(sa.String)
icon = sa.Column(sa.String)
file_name = sa.Column(sa.String)
def __init__(
self,
name=None,
icon=None,
file_name=None,
) -> None:
print("Asset Saved", name)
self.name = name
self.file_name = file_name
self.icon = icon

View File

@ -9,3 +9,4 @@ class RecipeInstruction(SqlAlchemyBase):
position = sa.Column(sa.Integer)
type = sa.Column(sa.String, default="")
text = sa.Column(sa.String)
title = sa.Column(sa.String)

View File

@ -10,6 +10,7 @@ class Nutrition(SqlAlchemyBase):
fatContent = sa.Column(sa.String)
fiberContent = sa.Column(sa.String)
proteinContent = sa.Column(sa.String)
carbohydrateContent = sa.Column(sa.String)
sodiumContent = sa.Column(sa.String)
sugarContent = sa.Column(sa.String)
@ -21,6 +22,7 @@ class Nutrition(SqlAlchemyBase):
proteinContent=None,
sodiumContent=None,
sugarContent=None,
carbohydrateContent=None,
) -> None:
self.calories = calories
self.fatContent = fatContent
@ -28,3 +30,4 @@ class Nutrition(SqlAlchemyBase):
self.proteinContent = proteinContent
self.sodiumContent = sodiumContent
self.sugarContent = sugarContent
self.carbohydrateContent = carbohydrateContent

View File

@ -1,16 +1,17 @@
import datetime
from datetime import date
from typing import List
import sqlalchemy as sa
import sqlalchemy.orm as orm
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
from mealie.db.models.recipe.api_extras import ApiExtras
from mealie.db.models.recipe.assets import RecipeAsset
from mealie.db.models.recipe.category import Category, recipes2categories
from mealie.db.models.recipe.ingredient import RecipeIngredient
from mealie.db.models.recipe.instruction import RecipeInstruction
from mealie.db.models.recipe.note import Note
from mealie.db.models.recipe.nutrition import Nutrition
from mealie.db.models.recipe.settings import RecipeSettings
from mealie.db.models.recipe.tag import Tag, recipes2tags
from mealie.db.models.recipe.tool import Tool
from sqlalchemy.ext.orderinglist import ordering_list
@ -32,17 +33,18 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
cookTime = sa.Column(sa.String)
recipeYield = sa.Column(sa.String)
recipeCuisine = sa.Column(sa.String)
tools: List[Tool] = orm.relationship("Tool", cascade="all, delete-orphan")
tools: list[Tool] = orm.relationship("Tool", cascade="all, delete-orphan")
assets: list[RecipeAsset] = orm.relationship("RecipeAsset", cascade="all, delete-orphan")
nutrition: Nutrition = orm.relationship("Nutrition", uselist=False, cascade="all, delete-orphan")
recipeCategory: List = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
recipeCategory: list = orm.relationship("Category", secondary=recipes2categories, back_populates="recipes")
recipeIngredient: List[RecipeIngredient] = orm.relationship(
recipeIngredient: list[RecipeIngredient] = orm.relationship(
"RecipeIngredient",
cascade="all, delete-orphan",
order_by="RecipeIngredient.position",
collection_class=ordering_list("position"),
)
recipeInstructions: List[RecipeInstruction] = orm.relationship(
recipeInstructions: list[RecipeInstruction] = orm.relationship(
"RecipeInstruction",
cascade="all, delete-orphan",
order_by="RecipeInstruction.position",
@ -51,12 +53,13 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
# Mealie Specific
slug = sa.Column(sa.String, index=True, unique=True)
tags: List[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
settings = orm.relationship("RecipeSettings", uselist=False, cascade="all, delete-orphan")
tags: list[Tag] = orm.relationship("Tag", secondary=recipes2tags, back_populates="recipes")
dateAdded = sa.Column(sa.Date, default=date.today)
notes: List[Note] = orm.relationship("Note", cascade="all, delete-orphan")
notes: list[Note] = orm.relationship("Note", cascade="all, delete-orphan")
rating = sa.Column(sa.Integer)
orgURL = sa.Column(sa.String)
extras: List[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
extras: list[ApiExtras] = orm.relationship("ApiExtras", cascade="all, delete-orphan")
@validates("name")
def validate_name(self, key, name):
@ -70,22 +73,24 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
description: str = None,
image: str = None,
recipeYield: str = None,
recipeIngredient: List[str] = None,
recipeInstructions: List[dict] = None,
recipeIngredient: list[str] = None,
recipeInstructions: list[dict] = None,
recipeCuisine: str = None,
totalTime: str = None,
prepTime: str = None,
nutrition: dict = None,
tools: list[str] = [],
tools: list[str] = None,
performTime: str = None,
slug: str = None,
recipeCategory: List[str] = None,
tags: List[str] = None,
recipeCategory: list[str] = None,
tags: list[str] = None,
dateAdded: datetime.date = None,
notes: List[dict] = None,
notes: list[dict] = None,
rating: int = None,
orgURL: str = None,
extras: dict = None,
assets: list = None,
settings: dict = None,
*args,
**kwargs
) -> None:
@ -95,12 +100,14 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.recipeCuisine = recipeCuisine
self.nutrition = Nutrition(**nutrition) if self.nutrition else Nutrition()
self.tools = [Tool(tool=x) for x in tools] if tools else []
self.recipeYield = recipeYield
self.recipeIngredient = [RecipeIngredient(ingredient=ingr) for ingr in recipeIngredient]
self.assets = [RecipeAsset(**a) for a in assets]
self.recipeInstructions = [
RecipeInstruction(text=instruc.get("text"), type=instruc.get("@type", None))
RecipeInstruction(text=instruc.get("text"), title=instruc.get("title"), type=instruc.get("@type", None))
for instruc in recipeInstructions
]
self.totalTime = totalTime
@ -110,6 +117,8 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.recipeCategory = [Category.create_if_not_exist(session=session, name=cat) for cat in recipeCategory]
# Mealie Specific
self.settings = RecipeSettings(**settings) if settings else RecipeSettings()
print(self.settings)
self.tags = [Tag.create_if_not_exist(session=session, name=tag) for tag in tags]
self.slug = slug
self.dateAdded = dateAdded
@ -118,54 +127,6 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
self.orgURL = orgURL
self.extras = [ApiExtras(key=key, value=value) for key, value in extras.items()]
def update(
self,
session,
name: str = None,
description: str = None,
image: str = None,
recipeYield: str = None,
recipeIngredient: List[str] = None,
recipeInstructions: List[dict] = None,
recipeCuisine: str = None,
totalTime: str = None,
tools: list[str] = [],
prepTime: str = None,
performTime: str = None,
nutrition: dict = None,
slug: str = None,
recipeCategory: List[str] = None,
tags: List[str] = None,
dateAdded: datetime.date = None,
notes: List[dict] = None,
rating: int = None,
orgURL: str = None,
extras: dict = None,
*args,
**kwargs
):
def update(self, *args, **kwargs):
"""Updated a database entry by removing nested rows and rebuilds the row through the __init__ functions"""
self.__init__(
session=session,
name=name,
description=description,
image=image,
recipeYield=recipeYield,
recipeIngredient=recipeIngredient,
recipeInstructions=recipeInstructions,
totalTime=totalTime,
recipeCuisine=recipeCuisine,
prepTime=prepTime,
performTime=performTime,
nutrition=nutrition,
tools=tools,
slug=slug,
recipeCategory=recipeCategory,
tags=tags,
dateAdded=dateAdded,
notes=notes,
rating=rating,
orgURL=orgURL,
extras=extras,
)
self.__init__(*args, **kwargs)

View File

@ -0,0 +1,18 @@
import sqlalchemy as sa
from mealie.db.models.model_base import SqlAlchemyBase
class RecipeSettings(SqlAlchemyBase):
__tablename__ = "recipe_settings"
id = sa.Column(sa.Integer, primary_key=True)
parent_id = sa.Column(sa.Integer, sa.ForeignKey("recipes.id"))
public = sa.Column(sa.Boolean)
show_nutrition = sa.Column(sa.Boolean)
show_assets = sa.Column(sa.Boolean)
landscape_view = sa.Column(sa.Boolean)
def __init__(self, public=True, show_nutrition=True, show_assets=True, landscape_view=True) -> None:
self.public = public
self.show_nutrition = show_nutrition
self.show_assets = show_assets
self.landscape_view = landscape_view

View File

@ -0,0 +1,50 @@
import shutil
from fastapi import APIRouter, Depends, File, Form
from fastapi.datastructures import UploadFile
from mealie.core.config import app_dirs
from mealie.db.database import db
from mealie.db.db_setup import generate_session
from mealie.routes.deps import get_current_user
from mealie.schema.recipe import Recipe, RecipeAsset
from mealie.schema.snackbar import SnackResponse
from slugify import slugify
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
router = APIRouter(prefix="/api/recipes", tags=["Recipe Assets"])
@router.get("/{recipe_slug}/asset")
async def get_recipe_asset(recipe_slug, file_name: str):
""" Returns a recipe asset """
file = app_dirs.RECIPE_DATA_DIR.joinpath(recipe_slug, file_name)
return FileResponse(file)
@router.post("/{recipe_slug}/asset", response_model=RecipeAsset)
def upload_recipe_asset(
recipe_slug: str,
name: str = Form(...),
icon: str = Form(...),
extension: str = Form(...),
file: UploadFile = File(...),
session: Session = Depends(generate_session),
current_user=Depends(get_current_user),
):
""" Upload a file to store as a recipe asset """
file_name = slugify(name) + "." + extension
asset_in = RecipeAsset(name=name, icon=icon, file_name=file_name)
dest = app_dirs.RECIPE_DATA_DIR.joinpath(recipe_slug, file_name)
dest.parent.mkdir(exist_ok=True, parents=True)
with dest.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
if dest.is_file():
recipe: Recipe = db.recipes.get(session, recipe_slug)
recipe.assets.append(asset_in)
db.recipes.update(session, recipe_slug, recipe.dict())
return asset_in
else:
return SnackResponse.error("Failure uploading file")

View File

@ -57,6 +57,7 @@ def update_recipe(
""" Updates a recipe by existing slug and data. """
recipe: Recipe = db.recipes.update(session, recipe_slug, data.dict())
print(recipe.assets)
if recipe_slug != recipe.slug:
rename_image(original_slug=recipe_slug, new_slug=recipe.slug)
@ -65,7 +66,7 @@ def update_recipe(
@router.patch("/{recipe_slug}")
def update_recipe(
def patch_recipe(
recipe_slug: str,
data: dict,
session: Session = Depends(generate_session),

View File

@ -1,12 +1,23 @@
import datetime
from typing import Any, List, Optional
from typing import Any, Optional
from fastapi_camelcase import CamelModel
from mealie.db.models.recipe.recipe import RecipeModel
from pydantic import BaseModel, validator
from pydantic import BaseModel, Field, validator
from pydantic.utils import GetterDict
from slugify import slugify
class RecipeSettings(CamelModel):
public: bool = True
show_nutrition: bool = True
show_assets: bool = True
landscape_view: bool = True
class Config:
orm_mode = True
class RecipeNote(BaseModel):
title: str
text: str
@ -15,18 +26,29 @@ class RecipeNote(BaseModel):
orm_mode = True
class RecipeStep(BaseModel):
class RecipeStep(CamelModel):
title: Optional[str] = ""
text: str
class Config:
orm_mode = True
class RecipeAsset(CamelModel):
name: str
icon: str
file_name: Optional[str]
class Config:
orm_mode = True
class Nutrition(BaseModel):
calories: Optional[str]
fatContent: Optional[str]
fiberContent: Optional[str]
proteinContent: Optional[str]
carbohydrateContent: Optional[str]
fiberContent: Optional[str]
sodiumContent: Optional[str]
sugarContent: Optional[str]
@ -41,8 +63,8 @@ class RecipeSummary(BaseModel):
image: Optional[Any]
description: Optional[str]
recipeCategory: Optional[List[str]] = []
tags: Optional[List[str]] = []
recipeCategory: Optional[list[str]] = []
tags: Optional[list[str]] = []
rating: Optional[int]
class Config:
@ -69,8 +91,10 @@ class Recipe(RecipeSummary):
performTime: Optional[str] = None
# Mealie Specific
settings: Optional[RecipeSettings]
assets: Optional[list[RecipeAsset]] = []
dateAdded: Optional[datetime.date]
notes: Optional[List[RecipeNote]] = []
notes: Optional[list[RecipeNote]] = []
orgURL: Optional[str]
extras: Optional[dict] = {}
@ -126,7 +150,7 @@ class Recipe(RecipeSummary):
class AllRecipeRequest(BaseModel):
properties: List[str]
properties: list[str]
limit: Optional[int]
class Config: