fix(backend): 🐛 Fix recipe page issues (#778)

* fix(backend): 🐛 Fix favorite assignment on backend

* fix(frontend): 🐛 fix printer button on recipe page

* style(frontend): 🚸 add user feadback on copy of recipe link

* fix(frontend): 🐛 Fix enableLandscape incorrect bindings to remove duplicate values

* feat(frontend):  add ingredient copy button for markdown list -[ ] format

* feat(frontend):  add remove prefix button to bulk entry

* fix(frontend): 🐛 disable random button when no recipes are present

* fix(frontend):  fix .zip download error

* fix(frontend): 🚸 close image dialog on upload/get

* fix(frontend): 🐛 fix assignment on creation for categories and tags

* feat(frontend):  Open editor on creation / fix edit button on main screen

* fix(frontend): 🐛 fix false negative regex match for urls on creationg page

* feat(frontend): 🚸 provide better user feadback when recipe exists

* feat(frontend):  lock bulk importer on submit

* remove zip from navigation

* fix(frontend):  rerender recipes on delete

Co-authored-by: Hayden K <hay-kot@pm.me>
This commit is contained in:
Hayden 2021-11-04 18:15:23 -08:00 committed by GitHub
parent ec3b53cdc3
commit 9f8c61a75a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 323 additions and 163 deletions

View File

@ -17,7 +17,8 @@ const routes = {
recipesParseIngredients: `${prefix}/parser/ingredients`, recipesParseIngredients: `${prefix}/parser/ingredients`,
recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`, recipesRecipeSlug: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}`,
recipesRecipeSlugZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/zip`, recipesRecipeSlugExport: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports`,
recipesRecipeSlugExportZip: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/exports/zip`,
recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`, recipesRecipeSlugImage: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/image`,
recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`, recipesRecipeSlugAssets: (recipe_slug: string) => `${prefix}/recipes/${recipe_slug}/assets`,
@ -72,6 +73,10 @@ export interface BulkCreatePayload {
imports: BulkCreateRecipe[]; imports: BulkCreateRecipe[];
} }
export interface RecipeZipToken {
token: string;
}
export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> { export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
baseRoute: string = routes.recipesBase; baseRoute: string = routes.recipesBase;
itemRoute = routes.recipesRecipeSlug; itemRoute = routes.recipesRecipeSlug;
@ -151,4 +156,12 @@ export class RecipeAPI extends BaseCRUDAPI<Recipe, CreateRecipe> {
parser = parser || "nlp"; parser = parser || "nlp";
return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient }); return await this.requests.post<ParsedIngredient>(routes.recipesParseIngredient, { parser, ingredient });
} }
async getZipToken(recipeSlug: string) {
return await this.requests.post<RecipeZipToken>(routes.recipesRecipeSlugExport(recipeSlug), {});
}
getZipRedirectUrl(recipeSlug: string, token: string) {
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
}
} }

View File

@ -49,6 +49,7 @@
fab fab
color="info" color="info"
:card-menu="false" :card-menu="false"
@print="$emit('print')"
/> />
</div> </div>
<div v-if="value" class="custom-btn-group mb-"> <div v-if="value" class="custom-btn-group mb-">

View File

@ -28,7 +28,7 @@
<RecipeRating :value="rating" :name="name" :slug="slug" :small="true" /> <RecipeRating :value="rating" :name="name" :slug="slug" :small="true" />
<v-spacer></v-spacer> <v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :is-category="false" /> <RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :is-category="false" />
<RecipeContextMenu :slug="slug" :name="name" /> <RecipeContextMenu :slug="slug" :name="name" @deleted="$emit('deleted', slug)" />
</v-card-actions> </v-card-actions>
<slot></slot> <slot></slot>
</v-card> </v-card>

View File

@ -6,7 +6,7 @@
</v-icon> </v-icon>
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title> <v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn :icon="$vuetify.breakpoint.xsOnly" text @click="navigateRandom"> <v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom">
<v-icon :left="!$vuetify.breakpoint.xsOnly"> <v-icon :left="!$vuetify.breakpoint.xsOnly">
{{ $globals.icons.diceMultiple }} {{ $globals.icons.diceMultiple }}
</v-icon> </v-icon>
@ -66,6 +66,7 @@
:rating="recipe.rating" :rating="recipe.rating"
:image="recipe.image" :image="recipe.image"
:tags="recipe.tags" :tags="recipe.tags"
@deleted="$emit('deleted', $event)"
/> />
</v-lazy> </v-lazy>
</v-col> </v-col>

View File

@ -93,14 +93,16 @@ export default defineComponent({
async select() { async select() {
const newItem = await (async () => { const newItem = await (async () => {
if (this.tagDialog) { if (this.tagDialog) {
const newItem = await this.api.tags.createOne({ name: this.itemName }); const { data } = await this.api.tags.createOne({ name: this.itemName });
return newItem; return data;
} else { } else {
const newItem = await this.api.categories.createOne({ name: this.itemName }); const { data } = await this.api.categories.createOne({ name: this.itemName });
return newItem; return data;
} }
})(); })();
console.log(newItem);
this.$emit(CREATED_ITEM_EVENT, newItem); this.$emit(CREATED_ITEM_EVENT, newItem);
this.dialog = false; this.dialog = false;
}, },

View File

@ -98,7 +98,7 @@ export default {
getAllCategories(); getAllCategories();
getAllTags(); getAllTags();
return { api, allTags, allCategories }; return { api, allTags, allCategories, getAllCategories, getAllTags };
}, },
data() { data() {
@ -152,6 +152,9 @@ export default {
}, },
pushToItem(createdItem) { pushToItem(createdItem) {
createdItem = this.returnObject ? createdItem : createdItem.name; createdItem = this.returnObject ? createdItem : createdItem.name;
// TODO: Remove excessive get calls
this.getAllCategories();
this.getAllTags();
this.selected.push(createdItem); this.selected.push(createdItem);
}, },
}, },

View File

@ -43,6 +43,7 @@
<script> <script>
import { defineComponent, ref } from "@nuxtjs/composition-api"; import { defineComponent, ref } from "@nuxtjs/composition-api";
import { useApiSingleton } from "~/composables/use-api"; import { useApiSingleton } from "~/composables/use-api";
import { alert } from "~/composables/use-toast";
export default defineComponent({ export default defineComponent({
props: { props: {
menuTop: { menuTop: {
@ -156,7 +157,7 @@ export default defineComponent({
}, },
}, },
methods: { methods: {
menuAction(action) { async menuAction(action) {
this.loading = true; this.loading = true;
switch (action) { switch (action) {
@ -182,10 +183,13 @@ export default defineComponent({
this.$router.push(`/recipe/${this.slug}` + "?edit=true"); this.$router.push(`/recipe/${this.slug}` + "?edit=true");
break; break;
case "print": case "print":
this.$router.push(`/recipe/${this.slug}` + "?print=true"); this.$emit("print");
break; break;
case "download": case "download":
window.open(`/api/recipes/${this.slug}/zip`); // TODO: Refacor this entire component to not suck so much
// eslint-disable-next-line
const { data } = await this.api.recipes.getZipToken(this.slug);
window.open(this.api.recipes.getZipRedirectUrl(this.slug, data.token));
break; break;
default: default:
break; break;
@ -194,16 +198,20 @@ export default defineComponent({
this.loading = false; this.loading = false;
}, },
async deleteRecipe() { async deleteRecipe() {
console.log("Delete Called");
await this.api.recipes.deleteOne(this.slug); await this.api.recipes.deleteOne(this.slug);
this.$emit("deleted");
}, },
updateClipboard() { updateClipboard() {
const copyText = this.recipeURL; const copyText = this.recipeURL;
navigator.clipboard.writeText(copyText).then( navigator.clipboard.writeText(copyText).then(
() => { () => {
console.log("Copied to Clipboard", copyText); console.log("Copied to Clipboard", copyText);
alert.success("Recipe link copied to clipboard");
}, },
() => console.log("Copied Failed", copyText) () => {
console.log("Copied Failed", copyText);
alert.error("Copied Failed");
}
); );
}, },
}, },

View File

@ -24,7 +24,23 @@
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')" :placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
> >
</v-textarea> </v-textarea>
<v-btn outlined color="info" small @click="trimAllLines"> Trim Whitespace </v-btn> <v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn outlined color="info" small v-bind="attrs" @click="trimAllLines" v-on="on">
Trim Whitespace
</v-btn>
</template>
<span> Trim leading and trailing whitespace as well as blank lines </span>
</v-tooltip>
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn class="ml-1" outlined color="info" small v-bind="attrs" @click="removeFirstCharacter" v-on="on">
Trim Prefix
</v-btn>
</template>
<span> Trim first character from each line </span>
</v-tooltip>
</v-card-text> </v-card-text>
<v-divider></v-divider> <v-divider></v-divider>
@ -52,14 +68,20 @@ export default defineComponent({
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line)); return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
} }
function trimAllLines() { function removeFirstCharacter() {
const splitLintes = splitText(); state.inputText = splitText()
.map((line) => line.substr(1))
.join("\n");
}
splitLintes.forEach((element: string, index: number) => { function trimAllLines() {
splitLintes[index] = element.trim(); const splitLines = splitText();
splitLines.forEach((element: string, index: number) => {
splitLines[index] = element.trim();
}); });
state.inputText = splitLintes.join("\n"); state.inputText = splitLines.join("\n");
} }
function save() { function save() {
@ -70,6 +92,7 @@ export default defineComponent({
return { return {
splitText, splitText,
trimAllLines, trimAllLines,
removeFirstCharacter,
save, save,
...toRefs(state), ...toRefs(state),
}; };

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-menu offset-y top nudge-top="6" :close-on-content-click="false"> <v-menu v-model="menu" offset-y top nudge-top="6" :close-on-content-click="false">
<template #activator="{ on, attrs }"> <template #activator="{ on, attrs }">
<v-btn color="accent" dark v-bind="attrs" v-on="on"> <v-btn color="accent" dark v-bind="attrs" v-on="on">
<v-icon left> <v-icon left>
@ -59,10 +59,12 @@ export default defineComponent({
data: () => ({ data: () => ({
url: "", url: "",
loading: false, loading: false,
menu: false,
}), }),
methods: { methods: {
uploadImage(fileObject) { uploadImage(fileObject) {
this.$emit(UPLOAD_EVENT, fileObject); this.$emit(UPLOAD_EVENT, fileObject);
this.menu = false;
}, },
async getImageFromURL() { async getImageFromURL() {
this.loading = true; this.loading = true;
@ -70,6 +72,7 @@ export default defineComponent({
this.$emit(REFRESH_EVENT); this.$emit(REFRESH_EVENT);
} }
this.loading = false; this.loading = false;
this.menu = false;
}, },
getMessages() { getMessages() {
return this.slug ? [""] : [this.$i18n.t("recipe.save-recipe-before-use")]; return this.slug ? [""] : [this.$i18n.t("recipe.save-recipe-before-use")];

View File

@ -1,6 +1,9 @@
<template> <template>
<div v-if="value && value.length > 0"> <div v-if="value && value.length > 0">
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2> <div class="d-flex justify-start">
<h2 class="mb-4 mt-1">{{ $t("recipe.ingredients") }}</h2>
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
</div>
<div> <div>
<div v-for="(ingredient, index) in value" :key="'ingredient' + index"> <div v-for="(ingredient, index) in value" :key="'ingredient' + index">
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3> <h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
@ -18,9 +21,10 @@
</template> </template>
<script> <script>
import { computed, defineComponent } from "@nuxtjs/composition-api";
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import { useFraction } from "@/composables/use-fraction"; import { useFraction } from "@/composables/use-fraction";
export default { export default defineComponent({
components: { components: {
VueMarkdown, VueMarkdown,
}, },
@ -65,7 +69,16 @@ export default {
return `${return_qty} ${unit?.name || " "} ${food?.name || " "} ${note}`; return `${return_qty} ${unit?.name || " "} ${food?.name || " "} ${note}`;
} }
return { parseIngredientText }; const ingredientCopyText = computed(() => {
// Returns a string of all ingredients in markdown list format -[ ]
return props.value
.map((ingredient) => {
return `- [ ] ${parseIngredientText(ingredient)}`;
})
.join("\n");
});
return { parseIngredientText, ingredientCopyText };
}, },
data() { data() {
return { return {
@ -101,7 +114,7 @@ export default {
this.$set(this.showTitleEditor, index, newVal); this.$set(this.showTitleEditor, index, newVal);
}, },
}, },
}; });
</script> </script>
<style> <style>

View File

@ -1,80 +1,89 @@
<template> <template>
<div class="container print"> <div>
<div> <div v-if="recipe" class="container print">
<h1> <div>
<svg class="icon" viewBox="0 0 24 24"> <h1>
<path <svg class="icon" viewBox="0 0 24 24">
fill="#E58325" <path
d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z" fill="#E58325"
/> d="M8.1,13.34L3.91,9.16C2.35,7.59 2.35,5.06 3.91,3.5L10.93,10.5L8.1,13.34M13.41,13L20.29,19.88L18.88,21.29L12,14.41L5.12,21.29L3.71,19.88L13.36,10.22L13.16,10C12.38,9.23 12.38,7.97 13.16,7.19L17.5,2.82L18.43,3.74L15.19,7L16.15,7.94L19.39,4.69L20.31,5.61L17.06,8.85L18,9.81L21.26,6.56L22.18,7.5L17.81,11.84C17.03,12.62 15.77,12.62 15,11.84L14.78,11.64L13.41,13Z"
</svg> />
{{ recipe.name }} </svg>
</h1> {{ recipe.name }}
</div> </h1>
<div class="time-container">
<RecipeTimeCard :prep-time="recipe.prepTime" :total-time="recipe.totalTime" :perform-time="recipe.performTime" />
</div>
<v-btn
v-if="recipe.recipeYield"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
</v-btn>
<div>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<h2>{{ $t("recipe.ingredients") }}</h2>
<ul>
<li v-for="(ingredient, index) in recipe.recipeIngredient" :key="index">
<v-icon>
{{ $globals.icons.checkboxBlankOutline }}
</v-icon>
<p>{{ ingredient.note }}</p>
</li>
</ul>
</div>
<div>
<h2>{{ $t("recipe.instructions") }}</h2>
<div v-for="(step, index) in recipe.recipeInstructions" :key="index">
<h2 v-if="step.title">{{ step.title }}</h2>
<div class="ml-5">
<h3>{{ $t("recipe.step-index", { step: index + 1 }) }}</h3>
<VueMarkdown :source="step.text"> </VueMarkdown>
</div>
</div> </div>
<div class="time-container">
<RecipeTimeCard
:prep-time="recipe.prepTime"
:total-time="recipe.totalTime"
:perform-time="recipe.performTime"
/>
</div>
<v-btn
v-if="recipe.recipeYield"
dense
small
:hover="false"
type="label"
:ripple="false"
elevation="0"
color="secondary darken-1"
class="rounded-sm static"
>
{{ recipe.recipeYield }}
</v-btn>
<div>
<VueMarkdown :source="recipe.description"> </VueMarkdown>
<h2>{{ $t("recipe.ingredients") }}</h2>
<ul>
<li v-for="(ingredient, index) in recipe.recipeIngredient" :key="index">
<v-icon>
{{ $globals.icons.checkboxBlankOutline }}
</v-icon>
<p>{{ ingredient.note }}</p>
</li>
</ul>
</div>
<div>
<h2>{{ $t("recipe.instructions") }}</h2>
<div v-for="(step, index) in recipe.recipeInstructions" :key="index">
<h2 v-if="step.title">{{ step.title }}</h2>
<div class="ml-5">
<h3>{{ $t("recipe.step-index", { step: index + 1 }) }}</h3>
<VueMarkdown :source="step.text"> </VueMarkdown>
</div>
</div>
<br /> <br />
<v-divider v-if="recipe.notes.length > 0" class="mb-5 mt-0"></v-divider> <v-divider v-if="recipe.notes.length > 0" class="mb-5 mt-0"></v-divider>
<div v-for="(note, index) in recipe.notes" :key="index + 'note'"> <div v-for="(note, index) in recipe.notes" :key="index + 'note'">
<h3>{{ note.title }}</h3> <h3>{{ note.title }}</h3>
<VueMarkdown :source="note.text"> </VueMarkdown> <VueMarkdown :source="note.text"> </VueMarkdown>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
// @ts-ignore
import VueMarkdown from "@adapttive/vue-markdown"; import VueMarkdown from "@adapttive/vue-markdown";
import RecipeTimeCard from "./RecipeTimeCard.vue"; import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
export default { import { Recipe } from "~/types/api-types/recipe";
export default defineComponent({
components: { components: {
RecipeTimeCard, RecipeTimeCard,
VueMarkdown, VueMarkdown,
}, },
props: { props: {
recipe: { recipe: {
type: Object, type: Object as () => Recipe,
required: true, required: true,
}, },
}, },
}; });
</script> </script>
<style> <style>

View File

@ -1,5 +1,5 @@
<template> <template>
<v-navigation-drawer class="d-flex flex-column" :value="value" clipped app width="240px"> <v-navigation-drawer class="d-flex flex-column d-print-none" :value="value" clipped app width="240px">
<!-- User Profile --> <!-- User Profile -->
<template v-if="$auth.user"> <template v-if="$auth.user">
<v-list-item two-line to="/user/profile" exact> <v-list-item two-line to="/user/profile" exact>

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="text-center"> <div class="text-center">
<v-snackbar v-model="toastAlert.open" top :color="toastAlert.color" timeout="1500" @input="toastAlert.open = false"> <v-snackbar v-model="toastAlert.open" top :color="toastAlert.color" timeout="2000" @input="toastAlert.open = false">
<v-icon dark left> <v-icon dark left>
{{ icon }} {{ icon }}
</v-icon> </v-icon>

View File

@ -14,6 +14,7 @@
:icon="icon" :icon="icon"
:color="color" :color="color"
retain-focus-on-click retain-focus-on-click
:class="btnClass"
@click=" @click="
on.click; on.click;
textToClipboard(); textToClipboard();
@ -48,6 +49,10 @@ export default {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
btnClass: {
type: String,
default: "",
},
}, },
data() { data() {
return { return {

View File

@ -1,11 +1,13 @@
const EMAIL_REGEX = const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(([[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const URL_REGEX = /^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,256}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)$/gm; const URL_REGEX = /[-a-zA-Z0-9@:%._+~#=]{1,256}.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
export const validators = { export const validators = {
required: (v: string) => !!v || "This Field is Required", required: (v: string) => !!v || "This Field is Required",
email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid", email: (v: string) => !v || EMAIL_REGEX.test(v) || "Email Must Be Valid",
whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed", whitespace: (v: string) => !v || v.split(" ").length <= 1 || "No Whitespace Allowed",
url: (v: string) => !v || URL_REGEX.test(v) || "Must Be A Valid URL", url: (v: string) => !v || URL_REGEX.test(v) || "Must Be A Valid URL",
} minLength: (min: number) => (v: string) => !v || v.length >= min || `Must Be At Least ${min} Characters`,
maxLength: (max: number) => (v: string) => !v || v.length <= max || `Must Be At Most ${max} Characters`,
};

View File

@ -120,14 +120,6 @@ export default defineComponent({
restricted: true, restricted: true,
}, },
{ divider: true }, { divider: true },
{
icon: this.$globals.icons.zip,
title: "Recipe from zip",
subtitle: "Restore from a exported recipe",
to: "/recipe/create?tab=zip",
restricted: true,
},
{ divider: true },
{ {
icon: this.$globals.icons.pages, icon: this.$globals.icons.pages,
title: "Cookbook", title: "Cookbook",

View File

@ -7,7 +7,7 @@
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3"> <v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader> <v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
</v-card> </v-card>
<v-card v-else-if="recipe"> <v-card v-else-if="recipe" class="d-print-none">
<!-- Recipe Header --> <!-- Recipe Header -->
<div class="d-flex justify-end flex-wrap align-stretch"> <div class="d-flex justify-end flex-wrap align-stretch">
<v-card v-if="!enableLandscape" width="50%" flat class="d-flex flex-column justify-center align-center"> <v-card v-if="!enableLandscape" width="50%" flat class="d-flex flex-column justify-center align-center">
@ -63,6 +63,7 @@
" "
@save="updateRecipe(recipe.slug, recipe)" @save="updateRecipe(recipe.slug, recipe)"
@delete="deleteRecipe(recipe.slug)" @delete="deleteRecipe(recipe.slug)"
@print="printRecipe"
/> />
<!-- Editors --> <!-- Editors -->
@ -78,7 +79,7 @@
<RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="uploadImage" /> <RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="uploadImage" />
</div> </div>
<!-- Recipe Title Section --> <!-- Recipe Title Section -->
<template v-if="!form && !enableLandscape"> <template v-if="!form && enableLandscape">
<v-card-title class="pa-0 ma-0 headline"> <v-card-title class="pa-0 ma-0 headline">
{{ recipe.name }} {{ recipe.name }}
</v-card-title> </v-card-title>
@ -161,7 +162,7 @@
<v-spacer></v-spacer> <v-spacer></v-spacer>
<RecipeRating <RecipeRating
v-if="!enableLandscape" v-if="enableLandscape"
:key="recipe.slug" :key="recipe.slug"
:value="recipe.rating" :value="recipe.rating"
:name="recipe.name" :name="recipe.name"
@ -263,6 +264,7 @@
</v-card-text> </v-card-text>
</div> </div>
</v-card> </v-card>
<RecipePrintView v-if="recipe" :recipe="recipe" />
</v-container> </v-container>
</template> </template>
@ -298,29 +300,31 @@ import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBt
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue"; import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue"; import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
import RecipeIngredientParserMenu from "~/components/Domain/Recipe/RecipeIngredientParserMenu.vue"; import RecipeIngredientParserMenu from "~/components/Domain/Recipe/RecipeIngredientParserMenu.vue";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import { Recipe } from "~/types/api-types/recipe"; import { Recipe } from "~/types/api-types/recipe";
import { useStaticRoutes } from "~/composables/api"; import { useStaticRoutes } from "~/composables/api";
import { uuid4 } from "~/composables/use-uuid"; import { uuid4 } from "~/composables/use-uuid";
export default defineComponent({ export default defineComponent({
components: { components: {
draggable,
RecipeActionMenu, RecipeActionMenu,
RecipeDialogBulkAdd,
RecipeAssets, RecipeAssets,
RecipeCategoryTagSelector, RecipeCategoryTagSelector,
RecipeChips, RecipeChips,
RecipeDialogBulkAdd,
RecipeImageUploadBtn, RecipeImageUploadBtn,
RecipeIngredientEditor,
RecipeIngredientParserMenu,
RecipeIngredients, RecipeIngredients,
RecipeInstructions, RecipeInstructions,
RecipeNotes, RecipeNotes,
RecipeNutrition, RecipeNutrition,
RecipePrintView,
RecipeRating, RecipeRating,
RecipeSettingsMenu, RecipeSettingsMenu,
RecipeIngredientEditor,
RecipeTimeCard, RecipeTimeCard,
RecipeIngredientParserMenu,
VueMarkdown, VueMarkdown,
draggable,
}, },
setup() { setup() {
const route = useRoute(); const route = useRoute();
@ -519,9 +523,25 @@ export default defineComponent({
// @ts-ignore // @ts-ignore
return this.$vuetify.breakpoint.xs ? "200" : "400"; return this.$vuetify.breakpoint.xs ? "200" : "400";
}, },
// Won't work with Composition API in Vue 2. In Vue 3, this will happen in the setup function.
edit: {
set(val) {
// @ts-ignore
this.$router.replace({ query: { ...this.$route.query, val } });
},
get() {
// @ts-ignore
return this.$route.query.edit;
},
},
},
mounted() {
if (this.edit) {
this.form = true;
}
}, },
methods: { methods: {
printPage() { printRecipe() {
window.print(); window.print();
}, },
}, },

View File

@ -287,12 +287,20 @@
</v-col> </v-col>
</v-row> </v-row>
<v-card-actions class="justify-end"> <v-card-actions class="justify-end">
<BaseButton delete @click="bulkUrls = []"> Clear </BaseButton> <BaseButton
delete
@click="
bulkUrls = [];
lockBulkImport = false;
"
>
Clear
</BaseButton>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<BaseButton color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })"> <BaseButton color="info" @click="bulkUrls.push({ url: '', categories: [], tags: [] })">
<template #icon> {{ $globals.icons.createAlt }} </template> New <template #icon> {{ $globals.icons.createAlt }} </template> New
</BaseButton> </BaseButton>
<BaseButton :disabled="bulkUrls.length === 0" @click="bulkCreate"> <BaseButton :disabled="bulkUrls.length === 0 || lockBulkImport" @click="bulkCreate">
<template #icon> {{ $globals.icons.check }} </template> Submit <template #icon> {{ $globals.icons.check }} </template> Submit
</BaseButton> </BaseButton>
</v-card-actions> </v-card-actions>
@ -352,13 +360,13 @@ export default defineComponent({
const api = useApiSingleton(); const api = useApiSingleton();
const router = useRouter(); const router = useRouter();
function handleResponse(response: any) { function handleResponse(response: any, edit: Boolean = false) {
if (response?.status !== 201) { if (response?.status !== 201) {
state.error = true; state.error = true;
state.loading = false; state.loading = false;
return; return;
} }
router.push(`/recipe/${response.data}`); router.push(`/recipe/${response.data}?edit=${edit}`);
} }
// =================================================== // ===================================================
@ -385,12 +393,17 @@ export default defineComponent({
async function createByUrl(url: string) { async function createByUrl(url: string) {
if (!domUrlForm.value.validate() || url === "") { if (!domUrlForm.value.validate() || url === "") {
console.log("Invalid URL", url);
return; return;
} }
state.loading = true; state.loading = true;
const { response } = await api.recipes.createOneByUrl(url); const { response } = await api.recipes.createOneByUrl(url);
if (response?.status !== 201) { if (response?.status !== 201) {
state.error = true; // @ts-ignore
if (!response?.error?.response?.data?.detail?.message) {
state.error = true;
}
state.loading = false; state.loading = false;
return; return;
} }
@ -408,7 +421,7 @@ export default defineComponent({
return; return;
} }
const { response } = await api.recipes.createOne({ name }); const { response } = await api.recipes.createOne({ name });
handleResponse(response); handleResponse(response, true);
} }
// =================================================== // ===================================================
@ -432,6 +445,7 @@ export default defineComponent({
// Bulk Importer // Bulk Importer
const bulkUrls = ref([{ url: "", categories: [], tags: [] }]); const bulkUrls = ref([{ url: "", categories: [], tags: [] }]);
const lockBulkImport = ref(false);
async function bulkCreate() { async function bulkCreate() {
if (bulkUrls.value.length === 0) { if (bulkUrls.value.length === 0) {
@ -442,6 +456,7 @@ export default defineComponent({
if (response?.status === 202) { if (response?.status === 202) {
alert.success("Bulk Import process has started"); alert.success("Bulk Import process has started");
lockBulkImport.value = true;
} else { } else {
alert.error("Bulk import process has failed"); alert.error("Bulk import process has failed");
} }
@ -450,6 +465,7 @@ export default defineComponent({
return { return {
bulkCreate, bulkCreate,
bulkUrls, bulkUrls,
lockBulkImport,
debugTreeView, debugTreeView,
tabs, tabs,
domCreateByName, domCreateByName,

View File

@ -4,6 +4,7 @@
:icon="$globals.icons.primary" :icon="$globals.icons.primary"
:title="$t('page.all-recipes')" :title="$t('page.all-recipes')"
:recipes="recipes" :recipes="recipes"
@deleted="removeRecipe"
></RecipeCardSection> ></RecipeCardSection>
<v-card v-intersect="infiniteScroll"></v-card> <v-card v-intersect="infiniteScroll"></v-card>
<v-fade-transition> <v-fade-transition>
@ -45,7 +46,18 @@ export default defineComponent({
loading.value = false; loading.value = false;
}, 500); }, 500);
return { recipes, infiniteScroll, loading }; function removeRecipe(slug: string) {
// @ts-ignore
for (let i = 0; i < recipes?.value?.length; i++) {
// @ts-ignore
if (recipes?.value[i].slug === slug) {
recipes?.value.splice(i, 1);
break;
}
}
}
return { recipes, infiniteScroll, loading, removeRecipe };
}, },
head() { head() {
return { return {

View File

@ -115,6 +115,23 @@ def validate_file_token(token: Optional[str] = None) -> Path:
return file_path return file_path
def validate_recipe_token(token: Optional[str] = None) -> str:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate file token",
)
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
slug = payload.get("slug")
except JWTError:
raise credentials_exception
return slug
async def temporary_zip_path() -> Path: async def temporary_zip_path() -> Path:
app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True) app_dirs.TEMP_DIR.mkdir(exist_ok=True, parents=True)
temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip") temp_path = app_dirs.TEMP_DIR.joinpath("my_zip_archive.zip")

View File

@ -25,11 +25,16 @@ def create_access_token(data: dict(), expires_delta: timedelta = None) -> str:
return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM) return jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM)
def create_file_token(file_path: Path) -> bool: def create_file_token(file_path: Path) -> str:
token_data = {"file": str(file_path)} token_data = {"file": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30)) return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def create_recipe_slug_token(file_path: str) -> str:
token_data = {"slug": str(file_path)}
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def authenticate_user(session, email: str, password: str) -> PrivateUser: def authenticate_user(session, email: str, password: str) -> PrivateUser:
db = get_database(session) db = get_database(session)

View File

@ -74,23 +74,23 @@ def handle_many_to_many(session, get_attr, relation_cls, all_elements: list[dict
return handle_one_to_many_list(session, get_attr, relation_cls, all_elements) return handle_one_to_many_list(session, get_attr, relation_cls, all_elements)
def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict]): def handle_one_to_many_list(session: Session, get_attr, relation_cls, all_elements: list[dict] | list[str]):
elems_to_create: list[dict] = [] elems_to_create: list[dict] = []
updated_elems: list[dict] = [] updated_elems: list[dict] = []
for elem in all_elements: for elem in all_elements:
elem_id = elem.get(get_attr, None) elem_id = elem.get(get_attr, None) if isinstance(elem, dict) else elem
existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none() existing_elem = session.query(relation_cls).filter_by(**{get_attr: elem_id}).one_or_none()
if existing_elem is None: if existing_elem is None:
elems_to_create.append(elem) elems_to_create.append(elem)
continue
else: elif isinstance(elem, dict):
for key, value in elem.items(): for key, value in elem.items():
setattr(existing_elem, key, value) setattr(existing_elem, key, value)
updated_elems.append(existing_elem) updated_elems.append(existing_elem)
new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create] new_elems = [safe_call(relation_cls, elem) for elem in elems_to_create]
return new_elems + updated_elems return new_elems + updated_elems

View File

@ -3,6 +3,7 @@ from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
from mealie.core.config import get_app_settings from mealie.core.config import get_app_settings
from .._model_base import BaseMixins, SqlAlchemyBase from .._model_base import BaseMixins, SqlAlchemyBase
from .._model_utils import auto_init
from ..group import Group from ..group import Group
from .user_to_favorite import users_to_favorites from .user_to_favorite import users_to_favorites
@ -56,41 +57,37 @@ class User(SqlAlchemyBase, BaseMixins):
favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by") favorite_recipes = orm.relationship("RecipeModel", secondary=users_to_favorites, back_populates="favorited_by")
def __init__( class Config:
self, exclude = {
session, "password",
full_name, "admin",
email, "can_manage",
password, "can_invite",
favorite_recipes: list[str] = None, "can_organize",
group: str = settings.DEFAULT_GROUP, "group",
advanced=False, "username",
**kwargs }
) -> None:
group = group or settings.DEFAULT_GROUP @auto_init()
favorite_recipes = favorite_recipes or [] def __init__(self, session, full_name, password, group: str = settings.DEFAULT_GROUP, **kwargs) -> None:
self.group = Group.get_ref(session, group) self.group = Group.get_ref(session, group)
self.full_name = full_name
self.email = email
self.password = password
self.advanced = advanced
self.favorite_recipes = [] self.favorite_recipes = []
self.password = password
if self.username is None: if self.username is None:
self.username = full_name self.username = full_name
self._set_permissions(**kwargs) self._set_permissions(**kwargs)
def update(self, full_name, email, group, username, session=None, favorite_recipes=None, advanced=False, **kwargs): @auto_init()
favorite_recipes = favorite_recipes or [] def update(self, full_name, email, group, username, session=None, **kwargs):
self.username = username self.username = username
self.full_name = full_name self.full_name = full_name
self.email = email self.email = email
self.group = Group.get_ref(session, group) self.group = Group.get_ref(session, group)
self.advanced = advanced
if self.username is None: if self.username is None:
self.username = full_name self.username = full_name

View File

@ -8,6 +8,7 @@ router = APIRouter()
router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"]) router.include_router(all_recipe_routes.router, prefix=prefix, tags=["Recipe: Query All"])
router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: Exports"]) router.include_router(recipe_export.user_router, prefix=prefix, tags=["Recipe: Exports"])
router.include_router(recipe_export.public_router, prefix=prefix, tags=["Recipe: Exports"])
router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"]) router.include_router(recipe_crud_routes.user_router, prefix=prefix, tags=["Recipe: CRUD"])
router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"]) router.include_router(image_and_assets.user_router, prefix=prefix, tags=["Recipe: Images and Assets"])
router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"]) router.include_router(comments.router, prefix=prefix, tags=["Recipe: Comments"])

View File

@ -1,18 +1,14 @@
from zipfile import ZipFile
from fastapi import Depends, File from fastapi import Depends, File
from fastapi.datastructures import UploadFile from fastapi.datastructures import UploadFile
from fastapi.encoders import jsonable_encoder from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy.orm.session import Session from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse
from mealie.core.dependencies import temporary_zip_path from mealie.core.dependencies import temporary_zip_path
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.db.database import get_database from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import CreateRecipeByUrl, Recipe, RecipeImageTypes from mealie.schema.recipe import CreateRecipeByUrl, Recipe
from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary from mealie.schema.recipe.recipe import CreateRecipe, CreateRecipeByUrlBulk, RecipeSummary
from mealie.schema.server.tasks import ServerTaskNames from mealie.schema.server.tasks import ServerTaskNames
from mealie.services.recipe.recipe_service import RecipeService from mealie.services.recipe.recipe_service import RecipeService
@ -109,23 +105,6 @@ def get_recipe(recipe_service: RecipeService = Depends(RecipeService.read_existi
return recipe_service.item return recipe_service.item
@user_router.get("/{slug}/zip")
async def get_recipe_as_zip(
slug: str, session: Session = Depends(generate_session), temp_path=Depends(temporary_zip_path)
):
""" Get a Recipe and It's Original Image as a Zip File """
db = get_database(session)
recipe: Recipe = db.recipes.get(slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(temp_path, filename=f"{slug}.zip")
@user_router.put("/{slug}") @user_router.put("/{slug}")
def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)): def update_recipe(data: Recipe, recipe_service: RecipeService = Depends(RecipeService.write_existing)):
""" Updates a recipe by existing slug and data. """ """ Updates a recipe by existing slug and data. """

View File

@ -1,14 +1,23 @@
from fastapi import Depends from zipfile import ZipFile
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm.session import Session
from starlette.responses import FileResponse from starlette.responses import FileResponse
from mealie.core.dependencies.dependencies import temporary_dir from mealie.core.dependencies import temporary_zip_path
from mealie.core.dependencies.dependencies import temporary_dir, validate_recipe_token
from mealie.core.root_logger import get_logger from mealie.core.root_logger import get_logger
from mealie.core.security import create_recipe_slug_token
from mealie.db.database import get_database
from mealie.db.db_setup import generate_session
from mealie.routes.routers import UserAPIRouter from mealie.routes.routers import UserAPIRouter
from mealie.schema.recipe import Recipe, RecipeImageTypes
from mealie.services.recipe.recipe_service import RecipeService from mealie.services.recipe.recipe_service import RecipeService
from mealie.services.recipe.template_service import TemplateService from mealie.services.recipe.template_service import TemplateService
user_router = UserAPIRouter() user_router = UserAPIRouter()
public_router = APIRouter()
logger = get_logger() logger = get_logger()
@ -23,6 +32,12 @@ async def get_recipe_formats_and_templates(_: RecipeService = Depends(RecipeServ
return TemplateService().templates return TemplateService().templates
@user_router.post("/{slug}/exports")
async def get_recipe_zip_token(slug: str):
""" Generates a recipe zip token to be used to download a recipe as a zip file """
return {"token": create_recipe_slug_token(slug)}
@user_router.get("/{slug}/exports", response_class=FileResponse) @user_router.get("/{slug}/exports", response_class=FileResponse)
def get_recipe_as_format( def get_recipe_as_format(
template_name: str, template_name: str,
@ -38,3 +53,28 @@ def get_recipe_as_format(
""" """
file = recipe_service.render_template(temp_dir, template_name) file = recipe_service.render_template(temp_dir, template_name)
return FileResponse(file) return FileResponse(file)
@public_router.get("/{slug}/exports/zip")
async def get_recipe_as_zip(
token: str,
slug: str,
session: Session = Depends(generate_session),
temp_path=Depends(temporary_zip_path),
):
""" Get a Recipe and It's Original Image as a Zip File """
slug = validate_recipe_token(token)
if slug != slug:
raise HTTPException(status_code=400, detail="Invalid Slug")
db = get_database(session)
recipe: Recipe = db.recipes.get(slug)
image_asset = recipe.image_dir.joinpath(RecipeImageTypes.original.value)
with ZipFile(temp_path, "w") as myzip:
myzip.writestr(f"{slug}.json", recipe.json())
if image_asset.is_file():
myzip.write(image_asset, arcname=image_asset.name)
return FileResponse(temp_path, filename=f"{slug}.zip")

View File

@ -26,9 +26,7 @@ def add_favorite(
): ):
""" Adds a Recipe to the users favorites """ """ Adds a Recipe to the users favorites """
assert_user_change_allowed(id, current_user)
current_user.favorite_recipes.append(slug) current_user.favorite_recipes.append(slug)
db = get_database(session) db = get_database(session)
db.users.update(current_user.id, current_user) db.users.update(current_user.id, current_user)