mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-25 08:09:41 +02:00
feat(frontend): 🚧 CRUD Functionality
This commit is contained in:
parent
00a8fdda41
commit
afcad2f701
49 changed files with 845 additions and 275 deletions
190
frontend/components/Domain/Recipe/RecipeActionMenu.vue
Normal file
190
frontend/components/Domain/Recipe/RecipeActionMenu.vue
Normal file
|
@ -0,0 +1,190 @@
|
|||
<template>
|
||||
<v-toolbar
|
||||
rounded
|
||||
height="0"
|
||||
class="fixed-bar mt-0"
|
||||
color="rgb(255, 0, 0, 0.0)"
|
||||
flat
|
||||
style="z-index: 2; position: sticky"
|
||||
:class="{ 'fixed-bar-mobile': $vuetify.breakpoint.xs }"
|
||||
>
|
||||
<BaseDialog
|
||||
ref="deleteRecipieConfirm"
|
||||
:title="$t('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
@confirm="emitDelete()"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<div v-if="!value" class="custom-btn-group ma-1">
|
||||
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="slug" show-always />
|
||||
<v-tooltip bottom color="info">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn
|
||||
v-if="loggedIn"
|
||||
fab
|
||||
small
|
||||
class="mx-1"
|
||||
color="info"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
@click="$emit('input', true)"
|
||||
>
|
||||
<v-icon> {{ $globals.icons.edit }} </v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ $t("general.edit") }}</span>
|
||||
</v-tooltip>
|
||||
<RecipeContextMenu
|
||||
show-print
|
||||
:menu-top="false"
|
||||
:slug="slug"
|
||||
:name="name"
|
||||
:menu-icon="$globals.icons.mdiDotsHorizontal"
|
||||
fab
|
||||
color="info"
|
||||
:card-menu="false"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="value" class="custom-btn-group mb-">
|
||||
<v-btn
|
||||
v-for="(btn, index) in editorButtons"
|
||||
:key="index"
|
||||
:fab="$vuetify.breakpoint.xs"
|
||||
:small="$vuetify.breakpoint.xs"
|
||||
class="mx-1"
|
||||
:color="btn.color"
|
||||
@click="emitHandler(btn.event)"
|
||||
>
|
||||
<v-icon :left="!$vuetify.breakpoint.xs">{{ btn.icon }}</v-icon>
|
||||
{{ $vuetify.breakpoint.xs ? "" : btn.text }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
|
||||
const SAVE_EVENT = "save";
|
||||
const DELETE_EVENT = "delete";
|
||||
const CLOSE_EVENT = "close";
|
||||
const JSON_EVENT = "json";
|
||||
|
||||
export default {
|
||||
components: { RecipeContextMenu, RecipeFavoriteBadge },
|
||||
props: {
|
||||
slug: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loggedIn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
edit: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
editorButtons() {
|
||||
return [
|
||||
{
|
||||
text: this.$t("general.delete"),
|
||||
icon: this.$globals.icons.delete,
|
||||
event: DELETE_EVENT,
|
||||
color: "error",
|
||||
},
|
||||
{
|
||||
text: this.$t("general.json"),
|
||||
icon: this.$globals.icons.codeBraces,
|
||||
event: JSON_EVENT,
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
text: this.$t("general.close"),
|
||||
icon: this.$globals.icons.close,
|
||||
event: CLOSE_EVENT,
|
||||
color: "",
|
||||
},
|
||||
{
|
||||
text: this.$t("general.save"),
|
||||
icon: this.$globals.icons.save,
|
||||
event: SAVE_EVENT,
|
||||
color: "success",
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
emitHandler(event) {
|
||||
switch (event) {
|
||||
case CLOSE_EVENT:
|
||||
this.$emit(CLOSE_EVENT);
|
||||
this.$emit("input", false);
|
||||
break;
|
||||
case SAVE_EVENT:
|
||||
this.$emit(SAVE_EVENT);
|
||||
break;
|
||||
case JSON_EVENT:
|
||||
this.$emit(JSON_EVENT);
|
||||
break;
|
||||
case DELETE_EVENT:
|
||||
this.$refs.deleteRecipieConfirm.open();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
emitDelete() {
|
||||
this.$emit(DELETE_EVENT);
|
||||
this.$emit("input", false);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-btn-group {
|
||||
flex: 0, 1, auto;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.vertical {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
|
||||
.sticky {
|
||||
margin-left: auto;
|
||||
position: fixed !important;
|
||||
margin-top: 4.25rem;
|
||||
}
|
||||
|
||||
.fixed-bar {
|
||||
position: sticky;
|
||||
position: -webkit-sticky; /* for Safari */
|
||||
top: 4.5em;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.fixed-bar-mobile {
|
||||
top: 1.5em !important;
|
||||
}
|
||||
</style>
|
168
frontend/components/Domain/Recipe/RecipeAssets.vue
Normal file
168
frontend/components/Domain/Recipe/RecipeAssets.vue
Normal file
|
@ -0,0 +1,168 @@
|
|||
<template>
|
||||
<div v-if="value.length > 0 || edit">
|
||||
<v-card class="mt-2">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("asset.assets") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-list v-if="value.length > 0" :flat="!edit">
|
||||
<v-list-item v-for="(item, i) in value" :key="i">
|
||||
<v-list-item-icon class="ma-auto">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-icon v-bind="attrs" v-on="on" v-text="getIconDefinition(item.icon).icon"></v-icon>
|
||||
</template>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="pl-2" v-text="item.name"></v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-list-item-action>
|
||||
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top>
|
||||
<v-icon> {{ $globals.icons.download }} </v-icon>
|
||||
</v-btn>
|
||||
<div v-else>
|
||||
<v-btn color="error" icon top @click="deleteAsset(i)">
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<AppCopyButton :copy-text="copyLink(item.fileName)" />
|
||||
</div>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
<div class="d-flex ml-auto mt-2">
|
||||
<v-spacer></v-spacer>
|
||||
<BaseDialog :title="$t('asset.new-asset')" :icon="getIconDefinition(newAsset.icon).icon" @submit="addAsset">
|
||||
<template #open="{ open }">
|
||||
<v-btn v-if="edit" color="secondary" dark @click="open">
|
||||
<v-icon>{{ $globals.icons.create }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card-text class="pt-2">
|
||||
<v-text-field v-model="newAsset.name" dense :label="$t('general.name')"></v-text-field>
|
||||
<div class="d-flex justify-space-between">
|
||||
<v-select
|
||||
v-model="newAsset.icon"
|
||||
dense
|
||||
:prepend-icon="getIconDefinition(newAsset.icon).icon"
|
||||
:items="iconOptions"
|
||||
item-text="title"
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<v-list-item-avatar>
|
||||
<v-icon class="mr-auto">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
</v-select>
|
||||
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" />
|
||||
</div>
|
||||
{{ fileObject.name }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
export default {
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const api = useApiSingleton();
|
||||
|
||||
return { api };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fileObject: {},
|
||||
newAsset: {
|
||||
name: "",
|
||||
icon: "mdi-file",
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
baseURL() {
|
||||
return window.location.origin;
|
||||
},
|
||||
iconOptions() {
|
||||
return [
|
||||
{
|
||||
name: "mdi-file",
|
||||
title: this.$i18n.t("asset.file"),
|
||||
icon: this.$globals.icons.file,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-pdf-box",
|
||||
title: this.$i18n.t("asset.pdf"),
|
||||
icon: this.$globals.icons.filePDF,
|
||||
},
|
||||
{
|
||||
name: "mdi-file-image",
|
||||
title: this.$i18n.t("asset.image"),
|
||||
icon: this.$globals.icons.fileImage,
|
||||
},
|
||||
{
|
||||
name: "mdi-code-json",
|
||||
title: this.$i18n.t("asset.code"),
|
||||
icon: this.$globals.icons.codeJson,
|
||||
},
|
||||
{
|
||||
name: "mdi-silverware-fork-knife",
|
||||
title: this.$i18n.t("asset.recipe"),
|
||||
icon: this.$globals.icons.primary,
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getIconDefinition(val) {
|
||||
return this.iconOptions.find(({ name }) => name === val);
|
||||
},
|
||||
assetURL(assetName) {
|
||||
return api.recipes.recipeAssetPath(this.slug, assetName);
|
||||
},
|
||||
setFileObject(obj) {
|
||||
this.fileObject = obj;
|
||||
},
|
||||
async addAsset() {
|
||||
const serverAsset = await api.recipes.createAsset(
|
||||
this.slug,
|
||||
this.fileObject,
|
||||
this.newAsset.name,
|
||||
this.newAsset.icon
|
||||
);
|
||||
this.value.push(serverAsset.data);
|
||||
this.newAsset = { name: "", icon: "mdi-file" };
|
||||
},
|
||||
deleteAsset(index) {
|
||||
this.value.splice(index, 1);
|
||||
},
|
||||
copyLink(fileName) {
|
||||
const assetLink = api.recipes.recipeAssetPath(this.slug, fileName);
|
||||
return `<img src="${this.baseURL}${assetLink}" height="100%" width="100%"> </img>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -1,40 +1,41 @@
|
|||
<template>
|
||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
||||
<v-card
|
||||
:class="{ 'on-hover': hover }"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:to="route ? `/recipe/${slug}` : ''"
|
||||
min-height="275"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<RecipeCardImage icon-size="200" :slug="slug" small :image-version="image">
|
||||
<v-expand-transition v-if="description">
|
||||
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%">
|
||||
<v-card-text class="v-card--text-show white--text">
|
||||
{{ description | truncate(300) }}
|
||||
</v-card-text>
|
||||
<v-lazy>
|
||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
||||
<v-card
|
||||
:class="{ 'on-hover': hover }"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:to="route ? `/recipe/${slug}` : ''"
|
||||
min-height="275"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<RecipeCardImage icon-size="200" :slug="slug" small :image-version="image">
|
||||
<v-expand-transition v-if="description">
|
||||
<div v-if="hover" class="d-flex transition-fast-in-fast-out secondary v-card--reveal" style="height: 100%">
|
||||
<v-card-text class="v-card--text-show white--text">
|
||||
{{ description }}
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</RecipeCardImage>
|
||||
<v-card-title class="my-n3 mb-n6">
|
||||
<div class="headerClass">
|
||||
{{ name }}
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</RecipeCardImage>
|
||||
<v-card-title class="my-n3 mb-n6">
|
||||
<div class="headerClass">
|
||||
{{ name }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
||||
<RecipeRating :value="rating" :name="name" :slug="slug" :small="true" />
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :is-category="false" />
|
||||
<RecipeContextMenu :slug="slug" :name="name" />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
<v-card-actions>
|
||||
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
||||
<RecipeRating :value="rating" :name="name" :slug="slug" :small="true" />
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" :is-category="false" />
|
||||
<RecipeContextMenu :slug="slug" :name="name" />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</v-lazy>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge";
|
||||
import RecipeChips from "./RecipeChips";
|
||||
import RecipeContextMenu from "./RecipeContextMenu";
|
||||
|
@ -82,11 +83,6 @@ export default {
|
|||
return this.$store.getters.getIsLoggedIn;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getImage(slug) {
|
||||
return api.recipes.recipeSmallImage(slug, this.image);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { useApi } from "~/composables/use-api";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
export default {
|
||||
props: {
|
||||
tiny: {
|
||||
|
@ -51,7 +51,7 @@ export default {
|
|||
},
|
||||
},
|
||||
setup() {
|
||||
const api = useApi();
|
||||
const api = useApiSingleton();
|
||||
|
||||
return { api };
|
||||
},
|
||||
|
|
|
@ -1,52 +1,55 @@
|
|||
<template>
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
class="mx-auto"
|
||||
hover
|
||||
:to="$listeners.selected ? undefined : `/recipe/${slug}`"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-list-item three-line>
|
||||
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
|
||||
<v-img
|
||||
v-if="!fallBackImage"
|
||||
:src="getImage(slug)"
|
||||
@load="fallBackImage = false"
|
||||
@error="fallBackImage = true"
|
||||
></v-img>
|
||||
<v-icon v-else color="primary" class="icon-position" size="100">
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="mb-1">{{ name }} </v-list-item-title>
|
||||
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
|
||||
<div class="d-flex justify-center align-center">
|
||||
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
||||
<v-rating
|
||||
color="secondary"
|
||||
class="ml-auto"
|
||||
background-color="secondary lighten-3"
|
||||
dense
|
||||
length="5"
|
||||
size="15"
|
||||
:value="rating"
|
||||
></v-rating>
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeContextMenu :slug="slug" :menu-icon="$globals.icons.dotsHorizontal" :name="name" />
|
||||
</div>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
<v-lazy>
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
class="mx-auto"
|
||||
hover
|
||||
:to="$listeners.selected ? undefined : `/recipe/${slug}`"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-list-item three-line>
|
||||
<v-list-item-avatar tile size="125" class="v-mobile-img rounded-sm my-0 ml-n4">
|
||||
<v-img
|
||||
v-if="!fallBackImage"
|
||||
:src="getImage(slug)"
|
||||
@load="fallBackImage = false"
|
||||
@error="fallBackImage = true"
|
||||
></v-img>
|
||||
<v-icon v-else color="primary" class="icon-position" size="100">
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="mb-1">{{ name }} </v-list-item-title>
|
||||
<v-list-item-subtitle> {{ description }} </v-list-item-subtitle>
|
||||
<div class="d-flex justify-center align-center">
|
||||
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
|
||||
<v-rating
|
||||
color="secondary"
|
||||
class="ml-auto"
|
||||
background-color="secondary lighten-3"
|
||||
dense
|
||||
length="5"
|
||||
size="15"
|
||||
:value="rating"
|
||||
></v-rating>
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeContextMenu :slug="slug" :menu-icon="$globals.icons.dotsHorizontal" :name="name" />
|
||||
</div>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
</v-lazy>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge";
|
||||
import RecipeContextMenu from "./RecipeContextMenu";
|
||||
export default {
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeFavoriteBadge,
|
||||
RecipeContextMenu,
|
||||
|
@ -81,6 +84,11 @@ export default {
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const api = useApiSingleton();
|
||||
|
||||
return { api };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
fallBackImage: false,
|
||||
|
@ -93,10 +101,10 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
getImage(slug) {
|
||||
return api.recipes.recipeSmallImage(slug, this.image);
|
||||
return this.api.recipes.recipeSmallImage(slug, this.image);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -117,7 +117,7 @@ export default {
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
titleIcon: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
|
@ -172,7 +172,7 @@ export default {
|
|||
return Math.min(this.hardLimit, this.recipes.length);
|
||||
},
|
||||
displayTitleIcon() {
|
||||
return this.titleIcon || this.$globals.icons.tags;
|
||||
return this.icon || this.$globals.icons.tags;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
@ -223,7 +223,6 @@ export default {
|
|||
console.log("Unknown Event", sortType);
|
||||
return;
|
||||
}
|
||||
|
||||
this.$emit(SORT_EVENT, sortTarget);
|
||||
this.sortLoading = false;
|
||||
},
|
||||
|
|
117
frontend/components/Domain/Recipe/RecipeComments.vue
Normal file
117
frontend/components/Domain/Recipe/RecipeComments.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<v-card>
|
||||
<v-card-title class="headline">
|
||||
<v-icon large class="mr-2">
|
||||
{{ $globals.icons.commentTextMultipleOutline }}
|
||||
</v-icon>
|
||||
{{ $t("recipe.comments") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-card v-for="(comment, index) in comments" :key="comment.id" class="ma-2">
|
||||
<v-list-item two-line>
|
||||
<v-list-item-avatar color="accent" class="white--text">
|
||||
<img :src="getProfileImage(comment.user.id)" />
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content>
|
||||
<v-list-item-title> {{ comment.user.username }}</v-list-item-title>
|
||||
<v-list-item-subtitle> {{ $d(new Date(comment.dateAdded), "short") }} </v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<v-card-actions v-if="loggedIn">
|
||||
<TheButton
|
||||
v-if="!editKeys[comment.id] && (user.admin || comment.user.id === user.id)"
|
||||
small
|
||||
minor
|
||||
delete
|
||||
@click="deleteComment(comment.id)"
|
||||
/>
|
||||
<TheButton
|
||||
v-if="!editKeys[comment.id] && comment.user.id === user.id"
|
||||
small
|
||||
edit
|
||||
@click="editComment(comment.id)"
|
||||
/>
|
||||
<TheButton v-else-if="editKeys[comment.id]" small update @click="updateComment(comment.id, index)" />
|
||||
</v-card-actions>
|
||||
</v-list-item>
|
||||
<div>
|
||||
<v-card-text>
|
||||
{{ !editKeys[comment.id] ? comment.text : null }}
|
||||
<v-textarea v-if="editKeys[comment.id]" v-model="comment.text"> </v-textarea>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-card>
|
||||
<v-card-text v-if="loggedIn">
|
||||
<v-textarea v-model="newComment" auto-grow row-height="1" outlined> </v-textarea>
|
||||
<div class="d-flex">
|
||||
<TheButton class="ml-auto" create @click="createNewComment"> {{ $t("recipe.comment-action") }} </TheButton>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
const NEW_COMMENT_EVENT = "new-comment";
|
||||
const UPDATE_COMMENT_EVENT = "update-comment";
|
||||
export default {
|
||||
props: {
|
||||
comments: {
|
||||
type: Array,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newComment: "",
|
||||
editKeys: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.getters.getUserData;
|
||||
},
|
||||
loggedIn() {
|
||||
return this.$store.getters.getIsLoggedIn;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
comments() {
|
||||
for (const comment of this.comments) {
|
||||
this.$set(this.editKeys, comment.id, false);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
resetImage() {
|
||||
this.hideImage == false;
|
||||
},
|
||||
getProfileImage(id) {
|
||||
return api.users.userProfileImage(id);
|
||||
},
|
||||
editComment(id) {
|
||||
this.$set(this.editKeys, id, true);
|
||||
},
|
||||
async updateComment(id, index) {
|
||||
this.$set(this.editKeys, id, false);
|
||||
|
||||
await api.recipes.updateComment(this.slug, id, this.comments[index]);
|
||||
this.$emit(UPDATE_COMMENT_EVENT);
|
||||
},
|
||||
async createNewComment() {
|
||||
await api.recipes.createComment(this.slug, { text: this.newComment });
|
||||
this.$emit(NEW_COMMENT_EVENT);
|
||||
|
||||
this.newComment = "";
|
||||
},
|
||||
async deleteComment(id) {
|
||||
await api.recipes.deleteComment(this.slug, id);
|
||||
this.$emit(UPDATE_COMMENT_EVENT);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
</style>
|
|
@ -1,13 +1,16 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<BaseDialog
|
||||
ref="deleteRecipieConfirm"
|
||||
ref="confirmDelete"
|
||||
:title="$t('recipe.delete-recipe')"
|
||||
:message="$t('recipe.delete-confirmation')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
@confirm="deleteRecipe()"
|
||||
/>
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
|
@ -38,9 +41,10 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
import { utils } from "@/utils";
|
||||
export default {
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { useApiSingleton } from "~/composables/use-api";
|
||||
export default defineComponent({
|
||||
props: {
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
|
@ -60,11 +64,14 @@ export default {
|
|||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
menuIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
cardMenu: {
|
||||
|
@ -72,6 +79,11 @@ export default {
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const api = useApiSingleton();
|
||||
const confirmDelete = ref(null);
|
||||
return { api, confirmDelete };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
|
@ -82,7 +94,7 @@ export default {
|
|||
return this.menuIcon ? this.menuIcon : this.$globals.icons.dotsVertical;
|
||||
},
|
||||
loggedIn() {
|
||||
return this.$store.getters.getIsLoggedIn;
|
||||
return this.$auth.loggedIn;
|
||||
},
|
||||
baseURL() {
|
||||
return window.location.origin;
|
||||
|
@ -145,12 +157,12 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
async menuAction(action) {
|
||||
menuAction(action) {
|
||||
this.loading = true;
|
||||
|
||||
switch (action) {
|
||||
case "delete":
|
||||
this.$refs.deleteRecipieConfirm.open();
|
||||
this.confirmDelete.open();
|
||||
break;
|
||||
case "share":
|
||||
if (navigator.share) {
|
||||
|
@ -183,7 +195,8 @@ export default {
|
|||
this.loading = false;
|
||||
},
|
||||
async deleteRecipe() {
|
||||
await api.recipes.delete(this.slug);
|
||||
console.log("Delete Called");
|
||||
await this.api.recipes.deleteOne(this.slug);
|
||||
},
|
||||
updateClipboard() {
|
||||
const copyText = this.recipeURL;
|
||||
|
@ -196,5 +209,5 @@ export default {
|
|||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
57
frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue
Normal file
57
frontend/components/Domain/Recipe/RecipeDialogBulkAdd.vue
Normal file
|
@ -0,0 +1,57 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="600">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="secondary lighten-2" dark v-bind="attrs" v-on="on" @click="inputText = ''">
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title class="headline"> {{ $t("new-recipe.bulk-add") }} </v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<p>
|
||||
{{ $t("new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list") }}
|
||||
</p>
|
||||
<v-textarea v-model="inputText"> </v-textarea>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="success" text @click="save"> {{ $t("general.save") }} </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
dialog: false,
|
||||
inputText: "",
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
splitText() {
|
||||
const split = this.inputText.split("\n");
|
||||
|
||||
split.forEach((element, index) => {
|
||||
if ((element === "\n") | (element == false)) {
|
||||
split.splice(index, 1);
|
||||
}
|
||||
});
|
||||
|
||||
return split;
|
||||
},
|
||||
save() {
|
||||
this.$emit("bulk-data", this.splitText());
|
||||
this.dialog = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="700">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="accent" dark v-bind="attrs" v-on="on"> {{ $t("recipe.api-extras") }} </v-btn>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title> {{ $t("recipe.api-extras") }} </v-card-title>
|
||||
|
||||
<v-card-text :key="formKey">
|
||||
<v-row v-for="(value, key, index) in extras" :key="index" align="center">
|
||||
<v-col cols="12" sm="1">
|
||||
<v-btn fab text x-small color="white" elevation="0" @click="removeExtra(key)">
|
||||
<v-icon color="error">{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3" sm="6">
|
||||
<v-text-field :label="$t('recipe.object-key')" :value="key" @input="updateKey(index)"> </v-text-field>
|
||||
</v-col>
|
||||
<v-col cols="12" md="8" sm="6">
|
||||
<v-text-field v-model="extras[key]" :label="$t('recipe.object-value')"> </v-text-field>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
|
||||
<v-card-actions>
|
||||
<v-form ref="addKey">
|
||||
<v-text-field
|
||||
v-model="newKeyName"
|
||||
:label="$t('recipe.new-key-name')"
|
||||
class="pr-4"
|
||||
:rules="[rules.required, rules.whiteSpace]"
|
||||
></v-text-field>
|
||||
</v-form>
|
||||
<v-btn color="info" text @click="append"> {{ $t("recipe.add-key") }} </v-btn>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn color="success" text @click="save"> {{ $t("general.save") }} </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
extras: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newKeyName: null,
|
||||
dialog: false,
|
||||
formKey: 1,
|
||||
rules: {
|
||||
required: v => !!v || this.$i18n.t("recipe.key-name-required"),
|
||||
whiteSpace: v => !v || v.split(" ").length <= 1 || this.$i18n.t("recipe.no-white-space-allowed"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
methods: {
|
||||
save() {
|
||||
this.$emit("save", this.extras);
|
||||
this.dialog = false;
|
||||
},
|
||||
append() {
|
||||
if (this.$refs.addKey.validate()) {
|
||||
this.extras[this.newKeyName] = "value";
|
||||
this.formKey += 1;
|
||||
}
|
||||
},
|
||||
removeExtra(key) {
|
||||
delete this.extras[key];
|
||||
this.formKey += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
|
@ -39,18 +39,18 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.getters.getUserData;
|
||||
return this.$auth.user;
|
||||
},
|
||||
isFavorite() {
|
||||
return this.user.favoriteRecipes.includes(this.slug);
|
||||
return this.$auth.user.favoriteRecipes.includes(this.slug);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async toggleFavorite() {
|
||||
if (!this.isFavorite) {
|
||||
await api.users.addFavorite(this.user.id, this.slug);
|
||||
await api.users.addFavorite(this.$auth.user.id, this.slug);
|
||||
} else {
|
||||
await api.users.removeFavorite(this.user.id, this.slug);
|
||||
await api.users.removeFavorite(this.$auth.user.id, this.slug);
|
||||
}
|
||||
this.$store.dispatch("requestUserData");
|
||||
},
|
||||
|
|
72
frontend/components/Domain/Recipe/RecipeImageUploadBtn.vue
Normal file
72
frontend/components/Domain/Recipe/RecipeImageUploadBtn.vue
Normal file
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="accent" dark v-bind="attrs" v-on="on">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.fileImage }}
|
||||
</v-icon>
|
||||
{{ $t("general.image") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card width="400">
|
||||
<v-card-title class="headline flex mb-0">
|
||||
<div>
|
||||
{{ $t("recipe.recipe-image") }}
|
||||
</div>
|
||||
<AppButtonUpload
|
||||
class="ml-auto"
|
||||
url="none"
|
||||
file-name="image"
|
||||
:text-btn="false"
|
||||
:post="false"
|
||||
@uploaded="uploadImage"
|
||||
/>
|
||||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div>
|
||||
<v-text-field v-model="url" :label="$t('general.url')" class="pt-5" clearable :messages="getMessages()">
|
||||
<template #append-outer>
|
||||
<v-btn class="ml-2" color="primary" :loading="loading" :disabled="!slug" @click="getImageFromURL">
|
||||
{{ $t("general.get") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { api } from "@/api";
|
||||
const REFRESH_EVENT = "refresh";
|
||||
const UPLOAD_EVENT = "upload";
|
||||
export default {
|
||||
props: {
|
||||
slug: String,
|
||||
},
|
||||
data: () => ({
|
||||
url: "",
|
||||
loading: false,
|
||||
}),
|
||||
methods: {
|
||||
uploadImage(fileObject) {
|
||||
this.$emit(UPLOAD_EVENT, fileObject);
|
||||
},
|
||||
async getImageFromURL() {
|
||||
this.loading = true;
|
||||
if (await api.recipes.updateImagebyURL(this.slug, this.url)) {
|
||||
this.$emit(REFRESH_EVENT);
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
getMessages() {
|
||||
return this.slug ? [""] : [this.$i18n.t("recipe.save-recipe-before-use")];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
167
frontend/components/Domain/Recipe/RecipeIngredients.vue
Normal file
167
frontend/components/Domain/Recipe/RecipeIngredients.vue
Normal file
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<div v-if="edit || (value && value.length > 0)">
|
||||
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
|
||||
<div v-if="edit">
|
||||
<draggable :value="value" handle=".handle" @input="updateIndex" @start="drag = true" @end="drag = false">
|
||||
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
|
||||
<div v-for="(ingredient, index) in value" :key="generateKey('ingredient', index)">
|
||||
<v-row align="center">
|
||||
<v-text-field
|
||||
v-if="edit && showTitleEditor[index]"
|
||||
v-model="value[index].title"
|
||||
class="mx-3 mt-3"
|
||||
dense
|
||||
:label="$t('recipe.section-title')"
|
||||
>
|
||||
</v-text-field>
|
||||
|
||||
<v-textarea
|
||||
v-model="value[index].note"
|
||||
class="mr-2"
|
||||
:label="$t('recipe.ingredient')"
|
||||
auto-grow
|
||||
solo
|
||||
dense
|
||||
rows="1"
|
||||
>
|
||||
<template slot="append">
|
||||
<v-tooltip right nudge-right="10">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn icon small class="mt-n1" v-bind="attrs" v-on="on" @click="toggleShowTitle(index)">
|
||||
<v-icon>{{ showTitleEditor[index] ? $globals.icons.minus : $globals.icons.createAlt }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{
|
||||
showTitleEditor[index] ? $t("recipe.remove-section") : $t("recipe.insert-section")
|
||||
}}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<template slot="append-outer">
|
||||
<v-icon class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
</template>
|
||||
<v-icon slot="prepend" class="mr-n1" color="error" @click="removeByIndex(value, index)">
|
||||
{{ $globals.icons.delete }}
|
||||
</v-icon>
|
||||
</v-textarea>
|
||||
</v-row>
|
||||
</div>
|
||||
</transition-group>
|
||||
</draggable>
|
||||
|
||||
<div class="d-flex row justify-end">
|
||||
<RecipeDialogBulkAdd class="mr-2" @bulk-data="addIngredient" />
|
||||
<v-btn color="secondary" dark class="mr-4" @click="addIngredient">
|
||||
<v-icon>{{ $globals.icons.create }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(ingredient, index) in value" :key="generateKey('ingredient', index)">
|
||||
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
|
||||
<v-divider v-if="showTitleEditor[index]"></v-divider>
|
||||
<v-list-item dense @click="toggleChecked(index)">
|
||||
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary"> </v-checkbox>
|
||||
<v-list-item-content>
|
||||
<VueMarkdown class="ma-0 pa-0 text-subtitle-1 dense-markdown" :source="ingredient.note"> </VueMarkdown>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import draggable from "vuedraggable";
|
||||
import { utils } from "@/utils";
|
||||
import RecipeDialogBulkAdd from "./RecipeDialogBulkAdd";
|
||||
export default {
|
||||
components: {
|
||||
RecipeDialogBulkAdd,
|
||||
draggable,
|
||||
VueMarkdown,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
},
|
||||
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
drag: false,
|
||||
checked: [],
|
||||
showTitleEditor: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
handler() {
|
||||
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.checked = this.value.map(() => false);
|
||||
this.showTitleEditor = this.value.map((x) => this.validateTitle(x.title));
|
||||
},
|
||||
methods: {
|
||||
addIngredient(ingredients = null) {
|
||||
if (ingredients.length) {
|
||||
const newIngredients = ingredients.map((x) => {
|
||||
return {
|
||||
title: null,
|
||||
note: x,
|
||||
unit: null,
|
||||
food: null,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
};
|
||||
});
|
||||
this.value.push(...newIngredients);
|
||||
} else {
|
||||
this.value.push({
|
||||
title: null,
|
||||
note: "",
|
||||
unit: null,
|
||||
food: null,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
},
|
||||
generateKey(item, index) {
|
||||
return utils.generateUniqueKey(item, index);
|
||||
},
|
||||
updateIndex(data) {
|
||||
this.$emit("input", data);
|
||||
},
|
||||
toggleChecked(index) {
|
||||
this.$set(this.checked, index, !this.checked[index]);
|
||||
},
|
||||
removeByIndex(list, index) {
|
||||
list.splice(index, 1);
|
||||
},
|
||||
validateTitle(title) {
|
||||
return !(title === null || title === "");
|
||||
},
|
||||
toggleShowTitle(index) {
|
||||
const newVal = !this.showTitleEditor[index];
|
||||
if (!newVal) {
|
||||
this.value[index].title = "";
|
||||
}
|
||||
this.$set(this.showTitleEditor, index, newVal);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.dense-markdown p {
|
||||
margin: auto !important;
|
||||
}
|
||||
</style>
|
169
frontend/components/Domain/Recipe/RecipeInstructions.vue
Normal file
169
frontend/components/Domain/Recipe/RecipeInstructions.vue
Normal file
|
@ -0,0 +1,169 @@
|
|||
<template>
|
||||
<div>
|
||||
<h2 class="mb-4">{{ $t("recipe.instructions") }}</h2>
|
||||
<div>
|
||||
<draggable
|
||||
:disabled="!edit"
|
||||
:value="value"
|
||||
handle=".handle"
|
||||
@input="updateIndex"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<div v-for="(step, index) in value" :key="index">
|
||||
<v-app-bar v-if="showTitleEditor[index]" class="primary mx-1 mt-6" dark dense rounded>
|
||||
<v-toolbar-title v-if="!edit" class="headline">
|
||||
<v-app-bar-title v-text="step.title"> </v-app-bar-title>
|
||||
</v-toolbar-title>
|
||||
<v-text-field
|
||||
v-if="edit"
|
||||
v-model="step.title"
|
||||
class="headline pa-0 mt-5"
|
||||
dense
|
||||
solo
|
||||
flat
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
background-color="primary"
|
||||
>
|
||||
</v-text-field>
|
||||
</v-app-bar>
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-card
|
||||
class="ma-1"
|
||||
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:ripple="!edit"
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<v-btn
|
||||
v-if="edit"
|
||||
fab
|
||||
x-small
|
||||
color="white"
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeByIndex(value, index)"
|
||||
>
|
||||
<v-icon size="24" color="error">{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
|
||||
{{ $t("recipe.step-index", { step: index + 1 }) }}
|
||||
|
||||
<v-btn v-if="edit" text color="primary" class="ml-auto" @click="toggleShowTitle(index)">
|
||||
{{ !showTitleEditor[index] ? $t("recipe.insert-section") : $t("recipe.remove-section") }}
|
||||
</v-btn>
|
||||
<v-icon v-if="edit" class="handle">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<v-fade-transition>
|
||||
<v-icon v-show="isChecked(index)" size="24" class="ml-auto" color="success">
|
||||
{{ $globals.icons.checkboxMarkedCircle }}
|
||||
</v-icon>
|
||||
</v-fade-transition>
|
||||
</v-card-title>
|
||||
<v-card-text v-if="edit">
|
||||
<v-textarea
|
||||
:key="generateKey('instructions', index)"
|
||||
v-model="value[index]['text']"
|
||||
auto-grow
|
||||
dense
|
||||
rows="4"
|
||||
>
|
||||
</v-textarea>
|
||||
</v-card-text>
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !edit" class="m-0 p-0">
|
||||
<v-card-text>
|
||||
<VueMarkdown :source="step.text"> </VueMarkdown>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</div>
|
||||
</draggable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import draggable from "vuedraggable";
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { utils } from "@/utils";
|
||||
export default {
|
||||
components: {
|
||||
VueMarkdown,
|
||||
draggable,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
},
|
||||
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
disabledSteps: [],
|
||||
showTitleEditor: [],
|
||||
};
|
||||
},
|
||||
|
||||
watch: {
|
||||
value: {
|
||||
handler() {
|
||||
this.disabledSteps = [];
|
||||
this.showTitleEditor = this.value.map(x => this.validateTitle(x.title));
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.showTitleEditor = this.value.map(x => this.validateTitle(x.title));
|
||||
},
|
||||
|
||||
methods: {
|
||||
generateKey(item, index) {
|
||||
return utils.generateUniqueKey(item, index);
|
||||
},
|
||||
removeByIndex(list, index) {
|
||||
list.splice(index, 1);
|
||||
},
|
||||
validateTitle(title) {
|
||||
return !(title === null || title === "");
|
||||
},
|
||||
toggleDisabled(stepIndex) {
|
||||
if (this.edit) return;
|
||||
if (this.disabledSteps.includes(stepIndex)) {
|
||||
const index = this.disabledSteps.indexOf(stepIndex);
|
||||
if (index !== -1) {
|
||||
this.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
this.disabledSteps.push(stepIndex);
|
||||
}
|
||||
},
|
||||
isChecked(stepIndex) {
|
||||
if (this.disabledSteps.includes(stepIndex) && !this.edit) {
|
||||
return "disabled-card";
|
||||
} else {
|
||||
|
||||
}
|
||||
},
|
||||
toggleShowTitle(index) {
|
||||
const newVal = !this.showTitleEditor[index];
|
||||
if (!newVal) {
|
||||
this.value[index].title = "";
|
||||
}
|
||||
this.$set(this.showTitleEditor, index, newVal);
|
||||
},
|
||||
updateIndex(data) {
|
||||
this.$emit("input", data);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
67
frontend/components/Domain/Recipe/RecipeNotes.vue
Normal file
67
frontend/components/Domain/Recipe/RecipeNotes.vue
Normal file
|
@ -0,0 +1,67 @@
|
|||
<template>
|
||||
<div v-if="value.length > 0 || edit">
|
||||
<h2 class="my-4">{{ $t("recipe.note") }}</h2>
|
||||
<v-card v-for="(note, index) in value" :key="generateKey('note', index)" class="mt-1">
|
||||
<div v-if="edit">
|
||||
<v-card-text>
|
||||
<v-row align="center">
|
||||
<v-btn fab x-small color="white" class="mr-2" elevation="0" @click="removeByIndex(value, index)">
|
||||
<v-icon color="error">{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<v-text-field v-model="value[index]['title']" :label="$t('recipe.title')"></v-text-field>
|
||||
</v-row>
|
||||
|
||||
<v-textarea v-model="value[index]['text']" auto-grow :placeholder="$t('recipe.note')"> </v-textarea>
|
||||
</v-card-text>
|
||||
</div>
|
||||
<div v-else>
|
||||
<v-card-title class="py-2">
|
||||
{{ note.title }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-card-text>
|
||||
<VueMarkdown :source="note.text"> </VueMarkdown>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
<div v-if="edit" class="d-flex justify-end">
|
||||
<v-btn class="mt-1" color="secondary" dark @click="addNote">
|
||||
<v-icon>{{ $globals.icons.create }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import { utils } from "@/utils";
|
||||
export default {
|
||||
components: {
|
||||
VueMarkdown,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
},
|
||||
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
generateKey(item, index) {
|
||||
return utils.generateUniqueKey(item, index);
|
||||
},
|
||||
addNote() {
|
||||
this.value.push({ title: "", text: "" });
|
||||
},
|
||||
removeByIndex(list, index) {
|
||||
list.splice(index, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
100
frontend/components/Domain/Recipe/RecipeNutrition.vue
Normal file
100
frontend/components/Domain/Recipe/RecipeNutrition.vue
Normal file
|
@ -0,0 +1,100 @@
|
|||
<template>
|
||||
<div v-if="valueNotNull || edit">
|
||||
<v-card class="mt-2">
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("recipe.nutrition") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-card-text v-if="edit">
|
||||
<div v-for="(item, key, index) in value" :key="index">
|
||||
<v-text-field
|
||||
dense
|
||||
:value="value[key]"
|
||||
:label="labels[key].label"
|
||||
:suffix="labels[key].suffix"
|
||||
type="number"
|
||||
autocomplete="off"
|
||||
@input="updateValue(key, $event)"
|
||||
></v-text-field>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-list v-if="showViewer" dense class="mt-0 pt-0">
|
||||
<v-list-item v-for="(item, key, index) in labels" :key="index">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title class="pl-4 text-subtitle-1 flex row ">
|
||||
<div>{{ item.label }}</div>
|
||||
<div class="ml-auto mr-1">{{ value[key] }}</div>
|
||||
<div>{{ item.suffix }}</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
labels: {
|
||||
calories: {
|
||||
label: this.$t("recipe.calories"),
|
||||
suffix: this.$t("recipe.calories-suffix"),
|
||||
},
|
||||
fatContent: {
|
||||
label: this.$t("recipe.fat-content"),
|
||||
suffix: this.$t("recipe.grams"),
|
||||
},
|
||||
fiberContent: {
|
||||
label: this.$t("recipe.fiber-content"),
|
||||
suffix: this.$t("recipe.grams"),
|
||||
},
|
||||
proteinContent: {
|
||||
label: this.$t("recipe.protein-content"),
|
||||
suffix: this.$t("recipe.grams"),
|
||||
},
|
||||
sodiumContent: {
|
||||
label: this.$t("recipe.sodium-content"),
|
||||
suffix: this.$t("recipe.milligrams"),
|
||||
},
|
||||
sugarContent: {
|
||||
label: this.$t("recipe.sugar-content"),
|
||||
suffix: this.$t("recipe.grams"),
|
||||
},
|
||||
carbohydrateContent: {
|
||||
label: this.$t("recipe.carbohydrate-content"),
|
||||
suffix: this.$t("recipe.grams"),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showViewer() {
|
||||
return !this.edit && this.valueNotNull;
|
||||
},
|
||||
valueNotNull() {
|
||||
for (const property in this.value) {
|
||||
const valueProperty = this.value[property];
|
||||
if (valueProperty && valueProperty !== "") return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateValue(key, value) {
|
||||
this.$emit("input", { ...this.value, [key]: value });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
155
frontend/components/Domain/Recipe/RecipePrintView.vue
Normal file
155
frontend/components/Domain/Recipe/RecipePrintView.vue
Normal file
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<div class="container print">
|
||||
<div>
|
||||
<h1>
|
||||
<svg class="icon" viewBox="0 0 24 24">
|
||||
<path
|
||||
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 }}
|
||||
</h1>
|
||||
</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 />
|
||||
<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'">
|
||||
<h3>{{ note.title }}</h3>
|
||||
<VueMarkdown :source="note.text"> </VueMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import VueMarkdown from "@adapttive/vue-markdown";
|
||||
import RecipeTimeCard from "./RecipeTimeCard.vue";
|
||||
export default {
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
VueMarkdown,
|
||||
},
|
||||
props: {
|
||||
recipe: Object,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
body,
|
||||
html {
|
||||
margin-top: -40px !important;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0 !important;
|
||||
display: -webkit-box;
|
||||
display: flex;
|
||||
font-size: 2rem;
|
||||
letter-spacing: -0.015625em;
|
||||
font-weight: 300;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
li {
|
||||
display: -webkit-box;
|
||||
display: -webkit-flex;
|
||||
margin-left: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
li p {
|
||||
margin-left: 0.25rem;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.03125em;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
margin-right: 0.5rem;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
.time-container {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
border-radius: 0.25rem;
|
||||
border-color: black;
|
||||
border: 1px;
|
||||
border-top: 1px;
|
||||
}
|
||||
|
||||
.print {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print {
|
||||
display: initial;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -48,7 +48,7 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
loggedIn() {
|
||||
return this.$store.getters.getIsLoggedIn;
|
||||
return this.$auth.loggedIn;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
|
|
60
frontend/components/Domain/Recipe/RecipeSettingsMenu.vue
Normal file
60
frontend/components/Domain/Recipe/RecipeSettingsMenu.vue
Normal file
|
@ -0,0 +1,60 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn color="accent" dark v-bind="attrs" v-on="on">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
{{ $t("general.settings") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-title class="py-2">
|
||||
<div>
|
||||
{{ $t("recipe.recipe-settings") }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-card-text class="mt-n5">
|
||||
<v-switch
|
||||
v-for="(itemValue, key) in value"
|
||||
:key="key"
|
||||
v-model="value[key]"
|
||||
dense
|
||||
flat
|
||||
inset
|
||||
:label="labels[key]"
|
||||
hide-details
|
||||
></v-switch>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
components: {},
|
||||
props: {
|
||||
value: Object,
|
||||
},
|
||||
|
||||
computed: {
|
||||
labels() {
|
||||
return {
|
||||
public: this.$t("recipe.public-recipe"),
|
||||
showNutrition: this.$t("recipe.show-nutrition-values"),
|
||||
showAssets: this.$t("asset.show-assets"),
|
||||
landscapeView: this.$t("recipe.landscape-view-coming-soon"),
|
||||
disableComments: this.$t("recipe.disable-comments"),
|
||||
disableAmount: this.$t("recipe.disable-amount"),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
methods: {},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
61
frontend/components/Domain/Recipe/RecipeTimeCard.vue
Normal file
61
frontend/components/Domain/Recipe/RecipeTimeCard.vue
Normal file
|
@ -0,0 +1,61 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-chip v-for="(time, index) in allTimes" :key="index" label color="accent custom-transparent" class="ma-1">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
{{ time.name }} |
|
||||
{{ time.value }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
prepTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
totalTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
performTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
showCards() {
|
||||
return [this.prepTime, this.totalTime, this.performTime].some((x) => !this.isEmpty(x));
|
||||
},
|
||||
allTimes() {
|
||||
return [this.validateTotalTime, this.validatePrepTime, this.validatePerformTime].filter((x) => x !== null);
|
||||
},
|
||||
validateTotalTime() {
|
||||
return !this.isEmpty(this.totalTime) ? { name: this.$t("recipe.total-time"), value: this.totalTime } : null;
|
||||
},
|
||||
validatePrepTime() {
|
||||
return !this.isEmpty(this.prepTime) ? { name: this.$t("recipe.prep-time"), value: this.prepTime } : null;
|
||||
},
|
||||
validatePerformTime() {
|
||||
return !this.isEmpty(this.performTime) ? { name: this.$t("recipe.perform-time"), value: this.performTime } : null;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isEmpty(str) {
|
||||
return !str || str.length === 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.time-card-flex {
|
||||
width: fit-content;
|
||||
}
|
||||
.custom-transparent {
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
Loading…
Add table
Add a link
Reference in a new issue