mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-31 12:15:42 -04:00
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:
parent
ec3b53cdc3
commit
9f8c61a75a
@ -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}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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-">
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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;
|
||||||
},
|
},
|
||||||
|
@ -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);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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");
|
||||||
|
}
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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),
|
||||||
};
|
};
|
||||||
|
@ -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")];
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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 {
|
||||||
|
@ -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`,
|
||||||
|
};
|
||||||
|
@ -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",
|
||||||
|
@ -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();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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")
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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"])
|
||||||
|
@ -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. """
|
||||||
|
@ -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")
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user