mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-24 01:12:54 -04:00
fix(frontend): 🐛 fix section titles carrying over on deleted items (#765)
* fix(frontend): 🐛 fix section titles carrying over on deleted items
Added a UUID generator to generate unique id's and prevent list changes from causing proper virtual dom re-renders.
* lazy load json editor
* fix ingredient rendering error
* move text to input
* update settings styling
* improve mobile view
Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
parent
909bc85205
commit
40462a95f1
@ -8,50 +8,71 @@
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title class="headline"> {{ $t("new-recipe.bulk-add") }} </v-card-title>
|
||||
<v-app-bar dark color="primary" class="mt-n1 mb-3">
|
||||
<v-icon large left>
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ $t("new-recipe.bulk-add") }}</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
|
||||
<v-card-text>
|
||||
<p>
|
||||
{{ $t("new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list") }}
|
||||
</p>
|
||||
<v-textarea v-model="inputText"> </v-textarea>
|
||||
<v-textarea
|
||||
v-model="inputText"
|
||||
outlined
|
||||
rows="10"
|
||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||
>
|
||||
</v-textarea>
|
||||
<v-btn outlined color="info" small @click="trimAllLines"> Trim Whitespace </v-btn>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="success" text @click="save"> {{ $t("general.save") }} </v-btn>
|
||||
<BaseButton save color="success" @click="save"> </BaseButton>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { reactive, toRefs } from "@nuxtjs/composition-api";
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
setup(_, context) {
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
inputText: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
splitText() {
|
||||
const split = this.inputText.split("\n");
|
||||
});
|
||||
|
||||
split.forEach((element, index) => {
|
||||
if ((element === "\n") | (element === false)) {
|
||||
split.splice(index, 1);
|
||||
}
|
||||
function splitText() {
|
||||
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
|
||||
}
|
||||
|
||||
function trimAllLines() {
|
||||
const splitLintes = splitText();
|
||||
|
||||
splitLintes.forEach((element: string, index: number) => {
|
||||
splitLintes[index] = element.trim();
|
||||
});
|
||||
|
||||
return split;
|
||||
},
|
||||
save() {
|
||||
this.$emit("bulk-data", this.splitText());
|
||||
this.dialog = false;
|
||||
},
|
||||
state.inputText = splitLintes.join("\n");
|
||||
}
|
||||
|
||||
function save() {
|
||||
context.emit("bulk-data", splitText());
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
return {
|
||||
splitText,
|
||||
trimAllLines,
|
||||
save,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -84,7 +84,7 @@
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template slot="append-outer">
|
||||
<v-icon class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<v-icon class="handle mt-1">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
|
@ -96,6 +96,7 @@ export default {
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
disabledSteps: [],
|
||||
|
@ -16,14 +16,14 @@
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-card-text class="mt-n5">
|
||||
<v-card-text class="mt-n5 pt-6 pb-2">
|
||||
<v-switch
|
||||
v-for="(itemValue, key) in value"
|
||||
:key="key"
|
||||
v-model="value[key]"
|
||||
xs
|
||||
dense
|
||||
flat
|
||||
inset
|
||||
class="my-1"
|
||||
:label="labels[key]"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
22
frontend/components/global/RecipeJsonEditor.vue
Normal file
22
frontend/components/global/RecipeJsonEditor.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<VJsoneditor v-model="value" height="1500px" :options="options" :attrs="$attrs"></VJsoneditor>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VJsoneditor from "v-jsoneditor";
|
||||
|
||||
export default {
|
||||
components: { VJsoneditor },
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -14,9 +14,17 @@ export const useRecipeContext = function () {
|
||||
}, slug);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
async function fetchRecipe(slug: string) {
|
||||
loading.value = true;
|
||||
const { data } = await api.recipes.getOne(slug);
|
||||
loading.value = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
async function deleteRecipe(slug: string) {
|
||||
loading.value = true;
|
||||
const { data } = await api.recipes.deleteOne(slug);
|
||||
@ -31,5 +39,5 @@ export const useRecipeContext = function () {
|
||||
return data;
|
||||
}
|
||||
|
||||
return { loading, getBySlug, deleteRecipe, updateRecipe };
|
||||
return { loading, getBySlug, deleteRecipe, updateRecipe, fetchRecipe };
|
||||
};
|
||||
|
12
frontend/composables/use-uuid.ts
Normal file
12
frontend/composables/use-uuid.ts
Normal file
@ -0,0 +1,12 @@
|
||||
const max = 1000000;
|
||||
|
||||
export function uniqueId() {
|
||||
return Date.now() + Math.random() * max;
|
||||
}
|
||||
|
||||
export function uuid4() {
|
||||
// @ts-ignore
|
||||
return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
|
||||
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
|
||||
);
|
||||
}
|
@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<v-container>
|
||||
<v-container
|
||||
:class="{
|
||||
'pa-0': $vuetify.breakpoint.smAndDown,
|
||||
}"
|
||||
>
|
||||
<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-card>
|
||||
<v-card v-else-if="recipe">
|
||||
<!-- Recipe Header -->
|
||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||
<v-card
|
||||
v-if="!recipe.settings.landscapeView"
|
||||
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">
|
||||
<v-card-text>
|
||||
<v-card-title class="headline pa-0 flex-column align-center">
|
||||
{{ recipe.name }}
|
||||
@ -21,6 +21,7 @@
|
||||
<v-divider></v-divider>
|
||||
<div class="d-flex justify-center mt-5">
|
||||
<RecipeTimeCard
|
||||
class="d-flex justify-center flex-wrap"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
@ -31,14 +32,14 @@
|
||||
</v-card>
|
||||
<v-img
|
||||
:key="imageKey"
|
||||
:max-width="recipe.settings.landscapeView ? null : '50%'"
|
||||
:max-width="enableLandscape ? null : '50%'"
|
||||
:height="hideImage ? '50' : imageHeight"
|
||||
:src="recipeImage(recipe.slug, imageKey)"
|
||||
class="d-print-none"
|
||||
@error="hideImage = true"
|
||||
>
|
||||
<RecipeTimeCard
|
||||
v-if="recipe.settings.landscapeView"
|
||||
v-if="enableLandscape"
|
||||
:class="true ? undefined : 'force-bottom'"
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
@ -54,8 +55,8 @@
|
||||
:logged-in="$auth.loggedIn"
|
||||
:open="form"
|
||||
class="ml-auto"
|
||||
@close="form = false"
|
||||
@json="jsonEditor = !jsonEditor"
|
||||
@close="closeEditor"
|
||||
@json="toggleJson"
|
||||
@edit="
|
||||
jsonEditor = false;
|
||||
form = true;
|
||||
@ -64,14 +65,20 @@
|
||||
@delete="deleteRecipe(recipe.slug)"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<v-card-text>
|
||||
<!-- Editors -->
|
||||
<LazyRecipeJsonEditor v-if="jsonEditor" v-model="recipe" class="mt-10" :options="jsonEditorOptions" />
|
||||
<div v-else>
|
||||
<v-card-text
|
||||
:class="{
|
||||
'px-2': $vuetify.breakpoint.smAndDown,
|
||||
}"
|
||||
>
|
||||
<div v-if="form" class="d-flex justify-start align-center">
|
||||
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
|
||||
<RecipeSettingsMenu class="my-1 mx-1" :value="recipe.settings" @upload="uploadImage" />
|
||||
</div>
|
||||
<!-- Recipe Title Section -->
|
||||
<template v-if="!form && recipe.settings.landscapeView">
|
||||
<template v-if="!form && !enableLandscape">
|
||||
<v-card-title class="pa-0 ma-0 headline">
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
@ -95,6 +102,7 @@
|
||||
|
||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')">
|
||||
</v-textarea>
|
||||
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')"> </v-text-field>
|
||||
</template>
|
||||
|
||||
<!-- Advanced Editor -->
|
||||
@ -103,10 +111,10 @@
|
||||
<draggable v-model="recipe.recipeIngredient" handle=".handle">
|
||||
<RecipeIngredientEditor
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="index + 'ing-editor'"
|
||||
:key="ingredient.ref"
|
||||
v-model="recipe.recipeIngredient[index]"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
@delete="removeByIndex(recipe.recipeIngredient, index)"
|
||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||
/>
|
||||
</draggable>
|
||||
<div class="d-flex justify-end mt-2">
|
||||
@ -115,9 +123,8 @@
|
||||
<BaseButton @click="addIngredient"> {{ $t("general.new") }} </BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-space-between align-center pb-3">
|
||||
<v-tooltip small top color="secondary darken-1">
|
||||
<v-tooltip v-if="!form" small top color="secondary darken-1">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn
|
||||
v-if="recipe.recipeYield"
|
||||
@ -139,7 +146,7 @@
|
||||
<span> Reset Scale </span>
|
||||
</v-tooltip>
|
||||
|
||||
<template v-if="!recipe.settings.disableAmount">
|
||||
<template v-if="!recipe.settings.disableAmount && !form">
|
||||
<v-btn color="secondary darken-1" class="mx-1" small @click="scale > 1 ? scale-- : null">
|
||||
<v-icon>
|
||||
{{ $globals.icons.minus }}
|
||||
@ -154,7 +161,7 @@
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<RecipeRating
|
||||
v-if="recipe.settings.landscapeView"
|
||||
v-if="!enableLandscape"
|
||||
:key="recipe.slug"
|
||||
:value="recipe.rating"
|
||||
:name="recipe.name"
|
||||
@ -222,8 +229,9 @@
|
||||
|
||||
<v-col cols="12" sm="12" md="8" lg="8">
|
||||
<RecipeInstructions v-model="recipe.recipeInstructions" :edit="form" />
|
||||
<div class="d-flex">
|
||||
<BaseButton v-if="form" class="ml-auto my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
||||
<div v-if="form" class="d-flex">
|
||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.new") }}</BaseButton>
|
||||
</div>
|
||||
<RecipeNotes v-model="recipe.notes" :edit="form" />
|
||||
</v-col>
|
||||
@ -263,8 +271,8 @@ import {
|
||||
computed,
|
||||
defineComponent,
|
||||
reactive,
|
||||
ref,
|
||||
toRefs,
|
||||
useContext,
|
||||
useMeta,
|
||||
useRoute,
|
||||
useRouter,
|
||||
@ -292,6 +300,7 @@ import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientE
|
||||
import RecipeIngredientParserMenu from "~/components/Domain/Recipe/RecipeIngredientParserMenu.vue";
|
||||
import { Recipe } from "~/types/api-types/recipe";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { uuid4 } from "~/composables/use-uuid";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
@ -318,21 +327,53 @@ export default defineComponent({
|
||||
const router = useRouter();
|
||||
const slug = route.value.params.slug;
|
||||
const api = useApiSingleton();
|
||||
const imageKey = ref(1);
|
||||
|
||||
const { getBySlug, loading } = useRecipeContext();
|
||||
const state = reactive({
|
||||
form: false,
|
||||
scale: 1,
|
||||
hideImage: false,
|
||||
imageKey: 1,
|
||||
loadFailed: false,
|
||||
skeleton: false,
|
||||
jsonEditor: false,
|
||||
jsonEditorOptions: {
|
||||
mode: "code",
|
||||
search: false,
|
||||
mainMenuBar: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { getBySlug, loading, fetchRecipe } = useRecipeContext();
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
|
||||
// @ts-ignore
|
||||
const { $vuetify } = useContext();
|
||||
|
||||
const recipe = getBySlug(slug);
|
||||
|
||||
const form = ref<boolean>(false);
|
||||
// ===========================================================================
|
||||
// Layout Helpers
|
||||
|
||||
useMeta(() => ({ title: recipe?.value?.name || "Recipe" }));
|
||||
const enableLandscape = computed(() => {
|
||||
const preferLandscape = recipe?.value?.settings?.landscapeView;
|
||||
const smallScreen = !$vuetify.breakpoint.smAndUp;
|
||||
|
||||
if (preferLandscape) {
|
||||
return true;
|
||||
} else if (smallScreen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Button Click Event Handlers
|
||||
|
||||
async function updateRecipe(slug: string, recipe: Recipe) {
|
||||
const { data } = await api.recipes.updateOne(slug, recipe);
|
||||
form.value = false;
|
||||
state.form = false;
|
||||
if (data?.slug) {
|
||||
router.push("/recipe/" + data.slug);
|
||||
}
|
||||
@ -345,6 +386,16 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
async function closeEditor() {
|
||||
state.form = false;
|
||||
state.jsonEditor = false;
|
||||
recipe.value = await fetchRecipe(slug);
|
||||
}
|
||||
|
||||
function toggleJson() {
|
||||
state.jsonEditor = !state.jsonEditor;
|
||||
}
|
||||
|
||||
const scaledYield = computed(() => {
|
||||
const regMatchNum = /\d+/;
|
||||
const yieldString = recipe.value?.recipeYield;
|
||||
@ -366,11 +417,7 @@ export default defineComponent({
|
||||
if (newVersion?.data?.version) {
|
||||
recipe.value.image = newVersion.data.version;
|
||||
}
|
||||
imageKey.value++;
|
||||
}
|
||||
|
||||
function removeByIndex(list: Array<any>, index: number) {
|
||||
list.splice(index, 1);
|
||||
state.imageKey++;
|
||||
}
|
||||
|
||||
function addStep(steps: Array<string> | null = null) {
|
||||
@ -393,10 +440,11 @@ export default defineComponent({
|
||||
if (ingredients?.length) {
|
||||
const newIngredients = ingredients.map((x) => {
|
||||
return {
|
||||
ref: uuid4(),
|
||||
title: "",
|
||||
note: x,
|
||||
unit: {},
|
||||
food: {},
|
||||
unit: null,
|
||||
food: null,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
};
|
||||
@ -407,24 +455,22 @@ export default defineComponent({
|
||||
}
|
||||
} else {
|
||||
recipe?.value?.recipeIngredient?.push({
|
||||
ref: uuid4(),
|
||||
title: "",
|
||||
note: "",
|
||||
unit: {},
|
||||
food: {},
|
||||
unit: null,
|
||||
food: null,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
// ===============================================================
|
||||
// Metadata
|
||||
|
||||
const structuredData = computed(() => {
|
||||
// TODO: Get this working with other scrapers, unsure why it isn't properly being delivered to clients.
|
||||
return {
|
||||
"@context": "http://schema.org",
|
||||
"@type": "Recipe",
|
||||
@ -450,34 +496,21 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
return {
|
||||
enableLandscape,
|
||||
scaledYield,
|
||||
toggleJson,
|
||||
...toRefs(state),
|
||||
imageKey,
|
||||
recipe,
|
||||
api,
|
||||
form,
|
||||
loading,
|
||||
addStep,
|
||||
deleteRecipe,
|
||||
closeEditor,
|
||||
updateRecipe,
|
||||
uploadImage,
|
||||
validators,
|
||||
recipeImage,
|
||||
addIngredient,
|
||||
removeByIndex,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hideImage: false,
|
||||
loadFailed: false,
|
||||
skeleton: false,
|
||||
jsonEditor: false,
|
||||
jsonEditorOptions: {
|
||||
mode: "code",
|
||||
search: false,
|
||||
mainMenuBar: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
head: {},
|
||||
|
@ -80,10 +80,11 @@ export interface Recipe {
|
||||
comments?: CommentOut[];
|
||||
}
|
||||
export interface RecipeIngredient {
|
||||
ref: string;
|
||||
title: string;
|
||||
note: string;
|
||||
unit: RecipeIngredientUnit;
|
||||
food: RecipeIngredientFood;
|
||||
unit?: RecipeIngredientUnit | null;
|
||||
food?: RecipeIngredientFood | null;
|
||||
disableAmount: boolean;
|
||||
quantity: number;
|
||||
}
|
||||
|
@ -1,7 +1,11 @@
|
||||
import enum
|
||||
from typing import Optional, Union
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi_camelcase import CamelModel
|
||||
from pydantic import Field
|
||||
|
||||
uuid4()
|
||||
|
||||
|
||||
class CreateIngredientFood(CamelModel):
|
||||
@ -36,6 +40,11 @@ class RecipeIngredient(CamelModel):
|
||||
disable_amount: bool = True
|
||||
quantity: float = 1
|
||||
|
||||
# Ref is used as a way to distinguish between an individual ingredient on the frontend
|
||||
# It is required for the reorder and section titles to function properly because of how
|
||||
# Vue handles reactivity. ref may serve another purpose in the future.
|
||||
ref: UUID = Field(default_factory=uuid4)
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user