mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-21 14:03:32 -07:00
feat: Migrate to Nuxt 3 framework (#5184)
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Docker Nightly Production / Backend Server Tests (push) Waiting to run
Docker Nightly Production / Frontend Tests (push) Waiting to run
Docker Nightly Production / Build Package (push) Waiting to run
Docker Nightly Production / Build Tagged Release (push) Blocked by required conditions
Docker Nightly Production / Notify Discord (push) Blocked by required conditions
Release Drafter / ✏️ Draft release (push) Waiting to run
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Docker Nightly Production / Backend Server Tests (push) Waiting to run
Docker Nightly Production / Frontend Tests (push) Waiting to run
Docker Nightly Production / Build Package (push) Waiting to run
Docker Nightly Production / Build Tagged Release (push) Blocked by required conditions
Docker Nightly Production / Notify Discord (push) Blocked by required conditions
Release Drafter / ✏️ Draft release (push) Waiting to run
Co-authored-by: Michael Genson <71845777+michael-genson@users.noreply.github.com> Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
parent
89ab7fac25
commit
c24d532608
403 changed files with 23959 additions and 19557 deletions
|
@ -1,33 +1,37 @@
|
|||
<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"
|
||||
style="z-index: 2; position: sticky; background: transparent; box-shadow: none;"
|
||||
density="compact"
|
||||
elevation="0"
|
||||
>
|
||||
<BaseDialog
|
||||
v-model="deleteDialog"
|
||||
:title="$tc('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
@confirm="emitDelete()"
|
||||
>
|
||||
<BaseDialog v-model="deleteDialog" :title="$t('recipe.delete-recipe')" color="error"
|
||||
:icon="$globals.icons.alertCircle" can-confirm @confirm="emitDelete()">
|
||||
<v-card-text>
|
||||
{{ $t("recipe.delete-confirmation") }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
<div v-if="!open" class="custom-btn-group ma-1">
|
||||
<RecipeFavoriteBadge v-if="loggedIn" class="ml-1" color="info" button-style :recipe-id="recipe.id" show-always />
|
||||
<RecipeTimelineBadge v-if="loggedIn" button-style class="ml-1" :slug="recipe.slug" :recipe-name="recipe.name" />
|
||||
<RecipeFavoriteBadge v-if="loggedIn" color="info" button-style :recipe-id="recipe.id!" show-always />
|
||||
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
|
||||
<div v-if="loggedIn">
|
||||
<v-tooltip v-if="canEdit" bottom color="info">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn fab small class="ml-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||
<v-icon> {{ $globals.icons.edit }} </v-icon>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="flat"
|
||||
rounded="circle"
|
||||
size="small"
|
||||
color="info"
|
||||
class="ml-1"
|
||||
v-bind="props"
|
||||
@click="$emit('edit', true)"
|
||||
>
|
||||
<v-icon size="x-large">
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ $t("general.edit") }}</span>
|
||||
|
@ -37,14 +41,14 @@
|
|||
<RecipeContextMenu
|
||||
show-print
|
||||
:menu-top="false"
|
||||
:name="recipe.name"
|
||||
:slug="recipe.slug"
|
||||
:name="recipe.name!"
|
||||
:slug="recipe.slug!"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
fab
|
||||
color="info"
|
||||
:card-menu="false"
|
||||
:recipe="recipe"
|
||||
:recipe-id="recipe.id"
|
||||
:recipe-id="recipe.id!"
|
||||
:recipe-scale="recipeScale"
|
||||
:use-items="{
|
||||
edit: false,
|
||||
|
@ -66,31 +70,33 @@
|
|||
<v-btn
|
||||
v-for="(btn, index) in editorButtons"
|
||||
:key="index"
|
||||
:fab="$vuetify.breakpoint.xs"
|
||||
:small="$vuetify.breakpoint.xs"
|
||||
:class="{ 'rounded-circle': $vuetify.display.xs }"
|
||||
:size="$vuetify.display.xs ? 'small' : undefined"
|
||||
:color="btn.color"
|
||||
variant="elevated"
|
||||
@click="emitHandler(btn.event)"
|
||||
>
|
||||
<v-icon :left="!$vuetify.breakpoint.xs">{{ btn.icon }}</v-icon>
|
||||
{{ $vuetify.breakpoint.xs ? "" : btn.text }}
|
||||
<v-icon :left="!$vuetify.display.xs">
|
||||
{{ btn.icon }}
|
||||
</v-icon>
|
||||
{{ $vuetify.display.xs ? "" : btn.text }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
const SAVE_EVENT = "save";
|
||||
const DELETE_EVENT = "delete";
|
||||
const CLOSE_EVENT = "close";
|
||||
const JSON_EVENT = "json";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
||||
props: {
|
||||
recipe: {
|
||||
|
@ -126,10 +132,12 @@ export default defineComponent({
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["print", "input", "delete", "close", "edit"],
|
||||
setup(_, context) {
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
const { i18n, $globals } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const editorButtons = [
|
||||
{
|
||||
text: i18n.t("general.delete"),
|
||||
|
@ -209,9 +217,13 @@ export default defineComponent({
|
|||
|
||||
.fixed-bar {
|
||||
position: sticky;
|
||||
position: -webkit-sticky; /* for Safari */
|
||||
top: 4.5em;
|
||||
z-index: 2;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
min-height: 0 !important;
|
||||
height: 48px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.fixed-bar-mobile {
|
||||
|
|
|
@ -4,71 +4,107 @@
|
|||
<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">
|
||||
{{ 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">
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
<v-divider class="mx-2" />
|
||||
<v-list
|
||||
v-if="value.length > 0"
|
||||
:flat="!edit"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, i) in value"
|
||||
:key="i"
|
||||
>
|
||||
<template #prepend>
|
||||
<div class="ma-auto">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ props }">
|
||||
<v-icon v-bind="props">
|
||||
{{ getIconDefinition(item.icon).icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<span>{{ getIconDefinition(item.icon).title }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<v-list-item-title class="pl-2">
|
||||
{{ item.name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-action>
|
||||
<v-btn v-if="!edit" color="primary" icon :href="assetURL(item.fileName)" target="_blank" top>
|
||||
<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="value.splice(i, 1)">
|
||||
<v-btn
|
||||
color="error"
|
||||
icon
|
||||
top
|
||||
@click="value.splice(i, 1)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
<AppButtonCopy color="" :copy-text="assetEmbed(item.fileName)" />
|
||||
<AppButtonCopy
|
||||
color=""
|
||||
:copy-text="assetEmbed(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>
|
||||
<v-spacer />
|
||||
<BaseDialog
|
||||
v-model="state.newAssetDialog"
|
||||
:title="$tc('asset.new-asset')"
|
||||
:title="$t('asset.new-asset')"
|
||||
:icon="getIconDefinition(state.newAsset.icon).icon"
|
||||
can-submit
|
||||
@submit="addAsset"
|
||||
>
|
||||
<template #activator>
|
||||
<BaseButton v-if="edit" small create @click="state.newAssetDialog = true" />
|
||||
<BaseButton
|
||||
v-if="edit"
|
||||
size="small"
|
||||
create
|
||||
@click="state.newAssetDialog = true"
|
||||
/>
|
||||
</template>
|
||||
<v-card-text class="pt-4">
|
||||
<v-text-field v-model="state.newAsset.name" dense :label="$t('general.name')"></v-text-field>
|
||||
<v-text-field
|
||||
v-model="state.newAsset.name"
|
||||
density="compact"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<div class="d-flex justify-space-between">
|
||||
<v-select
|
||||
v-model="state.newAsset.icon"
|
||||
dense
|
||||
density="compact"
|
||||
:prepend-icon="getIconDefinition(state.newAsset.icon).icon"
|
||||
:items="iconOptions"
|
||||
item-text="title"
|
||||
item-title="title"
|
||||
item-value="name"
|
||||
class="mr-2"
|
||||
>
|
||||
<template #item="{ item }">
|
||||
<v-list-item-avatar>
|
||||
<v-avatar>
|
||||
<v-icon class="mr-auto">
|
||||
{{ item.icon }}
|
||||
{{ item.raw.icon }}
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
</v-avatar>
|
||||
{{ item.title }}
|
||||
</template>
|
||||
</v-select>
|
||||
<AppButtonUpload :post="false" file-name="file" :text-btn="false" @uploaded="setFileObject" />
|
||||
<AppButtonUpload
|
||||
:post="false"
|
||||
file-name="file"
|
||||
:text-btn="false"
|
||||
@uploaded="setFileObject"
|
||||
/>
|
||||
</div>
|
||||
{{ state.fileObject.name }}
|
||||
</v-card-text>
|
||||
|
@ -78,13 +114,11 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { detectServerBaseUrl } from "~/composables/use-utils";
|
||||
import { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
import type { RecipeAsset } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
|
@ -94,7 +128,7 @@ export default defineComponent({
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => RecipeAsset[],
|
||||
required: true,
|
||||
},
|
||||
|
@ -103,6 +137,7 @@ export default defineComponent({
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
|
@ -115,7 +150,8 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
const { $globals, i18n, req } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const iconOptions = [
|
||||
{
|
||||
|
@ -145,10 +181,10 @@ export default defineComponent({
|
|||
},
|
||||
];
|
||||
|
||||
const serverBase = detectServerBaseUrl(req);
|
||||
const serverBase = useRequestURL().origin;
|
||||
|
||||
function getIconDefinition(icon: string) {
|
||||
return iconOptions.find((item) => item.name === icon) || iconOptions[0];
|
||||
return iconOptions.find(item => item.name === icon) || iconOptions[0];
|
||||
}
|
||||
|
||||
const { recipeAssetPath } = useStaticRoutes();
|
||||
|
@ -181,7 +217,7 @@ export default defineComponent({
|
|||
extension: state.fileObject.name.split(".").pop() || "",
|
||||
});
|
||||
|
||||
context.emit("input", [...props.value, data]);
|
||||
context.emit("update:modelValue", [...props.modelValue, data]);
|
||||
state.newAsset = { name: "", icon: "mdi-file" };
|
||||
state.fileObject = {} as File;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
<template>
|
||||
<v-lazy>
|
||||
<v-hover v-slot="{ hover }" :open-delay="50">
|
||||
<v-hover
|
||||
v-slot="{ isHovering, props }"
|
||||
:open-delay="50"
|
||||
>
|
||||
<v-card
|
||||
:class="{ 'on-hover': hover }"
|
||||
v-bind="props"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:style="{ cursor }"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
:to="recipeRoute"
|
||||
:min-height="imageHeight + 75"
|
||||
@click.self="$emit('click')"
|
||||
|
@ -14,11 +18,15 @@
|
|||
:height="imageHeight"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
small
|
||||
size="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%">
|
||||
<div
|
||||
v-if="isHovering"
|
||||
class="d-flex transition-fast-in-fast-out bg-secondary v-card--reveal"
|
||||
style="height: 100%"
|
||||
>
|
||||
<v-card-text class="v-card--text-show white--text">
|
||||
<div class="descriptionWrapper">
|
||||
<SafeMarkdown :source="description" />
|
||||
|
@ -27,24 +35,47 @@
|
|||
</div>
|
||||
</v-expand-transition>
|
||||
</RecipeCardImage>
|
||||
<v-card-title class="my-n3 px-2 mb-n6">
|
||||
<v-card-title class="mb-n3 px-4">
|
||||
<div class="headerClass">
|
||||
{{ name }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<slot name="actions">
|
||||
<v-card-actions v-if="showRecipeContent" class="px-1">
|
||||
<RecipeFavoriteBadge v-if="isOwnGroup" class="absolute" :recipe-id="recipeId" show-always />
|
||||
<v-card-actions
|
||||
v-if="showRecipeContent"
|
||||
class="px-1"
|
||||
>
|
||||
<RecipeFavoriteBadge
|
||||
v-if="isOwnGroup"
|
||||
class="absolute"
|
||||
:recipe-id="recipeId"
|
||||
show-always
|
||||
/>
|
||||
<div v-else class="px-1" /> <!-- Empty div to keep the layout consistent -->
|
||||
|
||||
<RecipeRating class="pb-1" :value="rating" :recipe-id="recipeId" :slug="slug" :small="true" />
|
||||
<v-spacer></v-spacer>
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
|
||||
<RecipeRating
|
||||
class="ml-n2"
|
||||
:value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
small
|
||||
/>
|
||||
<v-spacer />
|
||||
<RecipeChips
|
||||
:truncate="true"
|
||||
:items="tags"
|
||||
:title="false"
|
||||
:limit="2"
|
||||
small
|
||||
url-prefix="tags"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup"
|
||||
color="grey darken-2"
|
||||
color="grey-darken-2"
|
||||
:slug="slug"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
|
@ -62,14 +93,13 @@
|
|||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
<slot></slot>
|
||||
<slot />
|
||||
</v-card>
|
||||
</v-hover>
|
||||
</v-lazy>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
|
@ -77,7 +107,7 @@ import RecipeCardImage from "./RecipeCardImage.vue";
|
|||
import RecipeRating from "./RecipeRating.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
|
||||
props: {
|
||||
name: {
|
||||
|
@ -119,12 +149,13 @@ export default defineComponent({
|
|||
default: 200,
|
||||
},
|
||||
},
|
||||
emits: ["click", "delete"],
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
|
@ -159,7 +190,7 @@ export default defineComponent({
|
|||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.descriptionWrapper{
|
||||
.descriptionWrapper {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 8;
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
<v-img
|
||||
v-if="!fallBackImage"
|
||||
:height="height"
|
||||
cover
|
||||
min-height="125"
|
||||
max-height="fill-height"
|
||||
:src="getImage(recipeId)"
|
||||
|
@ -9,21 +10,28 @@
|
|||
@load="fallBackImage = false"
|
||||
@error="fallBackImage = true"
|
||||
>
|
||||
<slot> </slot>
|
||||
<slot />
|
||||
</v-img>
|
||||
<div v-else class="icon-slot" @click="$emit('click')">
|
||||
<v-icon color="primary" class="icon-position" :size="iconSize">
|
||||
<div
|
||||
v-else
|
||||
class="icon-slot"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<v-icon
|
||||
color="primary"
|
||||
class="icon-position"
|
||||
:size="iconSize"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
<slot> </slot>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
|
@ -55,9 +63,10 @@ export default defineComponent({
|
|||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
default: "fill-height",
|
||||
default: "100%",
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
|
||||
|
@ -75,7 +84,7 @@ export default defineComponent({
|
|||
() => props.recipeId,
|
||||
() => {
|
||||
fallBackImage.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function getImage(recipeId: string) {
|
||||
|
|
|
@ -1,81 +1,121 @@
|
|||
<template>
|
||||
<div :style="`height: ${height}`">
|
||||
<div :style="`height: ${height}px;`">
|
||||
<v-expand-transition>
|
||||
<v-card
|
||||
:ripple="false"
|
||||
:class="isFlat ? 'mx-auto flat' : 'mx-auto'"
|
||||
:style="{ cursor }"
|
||||
hover
|
||||
:to="$listeners.selected ? undefined : recipeRoute"
|
||||
height="100%"
|
||||
:to="$attrs.selected ? undefined : recipeRoute"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-img v-if="vertical" class="rounded-sm">
|
||||
<v-img
|
||||
v-if="vertical"
|
||||
class="rounded-sm"
|
||||
cover
|
||||
>
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="height"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
small
|
||||
size="small"
|
||||
:image-version="image"
|
||||
:height="height"
|
||||
/>
|
||||
</v-img>
|
||||
<v-list-item three-line :class="vertical ? 'px-2' : 'px-0'">
|
||||
<slot v-if="!vertical" name="avatar">
|
||||
<v-list-item-avatar tile :height="height" width="125" class="v-mobile-img rounded-sm my-0">
|
||||
<v-list-item
|
||||
lines="two"
|
||||
class="py-0"
|
||||
:class="vertical ? 'px-2' : 'px-0'"
|
||||
item-props
|
||||
height="100%"
|
||||
density="compact"
|
||||
>
|
||||
<template #prepend>
|
||||
<slot
|
||||
v-if="!vertical"
|
||||
name="avatar"
|
||||
>
|
||||
<RecipeCardImage
|
||||
:icon-size="100"
|
||||
:height="height"
|
||||
:slug="slug"
|
||||
:recipe-id="recipeId"
|
||||
:image-version="image"
|
||||
size="small"
|
||||
width="125"
|
||||
:height="height"
|
||||
/>
|
||||
</slot>
|
||||
</template>
|
||||
<div class="pl-4 d-flex flex-column justify-space-between align-stretch pr-2">
|
||||
<v-list-item-title class="mt-3 mb-1 text-top text-truncate w-100">
|
||||
{{ name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="ma-0 text-top">
|
||||
<SafeMarkdown v-if="description" :source="description" />
|
||||
<p v-else>
|
||||
<br>
|
||||
<br>
|
||||
<br>
|
||||
</p>
|
||||
</v-list-item-subtitle>
|
||||
<div
|
||||
class="d-flex flex-nowrap justify-start ma-0 pt-2 pb-0"
|
||||
style="overflow-x: hidden; overflow-y: hidden; white-space: nowrap;"
|
||||
>
|
||||
<RecipeChips
|
||||
:truncate="true"
|
||||
:items="tags"
|
||||
:title="false"
|
||||
:limit="2"
|
||||
small
|
||||
url-prefix="tags"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="actions">
|
||||
<v-card-actions class="w-100 my-0 px-1 py-0">
|
||||
<RecipeFavoriteBadge
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
:recipe-id="recipeId"
|
||||
show-always
|
||||
class="ma-0 pa-0"
|
||||
/>
|
||||
<div v-else class="my-0 px-1 py-0" /> <!-- Empty div to keep the layout consistent -->
|
||||
<RecipeRating
|
||||
v-if="showRecipeContent"
|
||||
:class="[{ 'pb-2': !isOwnGroup }, 'ml-n2']"
|
||||
:value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
small
|
||||
/>
|
||||
</v-list-item-avatar>
|
||||
</slot>
|
||||
<v-list-item-content class="py-0">
|
||||
<v-list-item-title class="mt-1 mb-1 text-top">{{ name }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="ma-0 text-top">
|
||||
<SafeMarkdown :source="description" />
|
||||
</v-list-item-subtitle>
|
||||
<div class="d-flex flex-wrap justify-start ma-0">
|
||||
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" v-on="$listeners" />
|
||||
</div>
|
||||
<div class="d-flex flex-wrap justify-end align-center">
|
||||
<slot name="actions">
|
||||
<RecipeFavoriteBadge v-if="isOwnGroup && showRecipeContent" :recipe-id="recipeId" show-always />
|
||||
<RecipeRating
|
||||
v-if="showRecipeContent"
|
||||
:class="isOwnGroup ? 'ml-auto' : 'ml-auto pb-2'"
|
||||
:value="rating"
|
||||
:recipe-id="recipeId"
|
||||
:slug="slug"
|
||||
:small="true"
|
||||
/>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<!-- We also add padding to the v-rating above to compensate -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsHorizontal"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</v-list-item-content>
|
||||
<!-- If we're not logged-in, no items display, so we hide this menu -->
|
||||
<!-- We also add padding to the v-rating above to compensate -->
|
||||
<RecipeContextMenu
|
||||
v-if="isOwnGroup && showRecipeContent"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.dotsHorizontal"
|
||||
:name="name"
|
||||
:recipe-id="recipeId"
|
||||
class="ml-auto"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: false,
|
||||
printPreferences: false,
|
||||
share: true,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</slot>
|
||||
</v-list-item>
|
||||
<slot />
|
||||
</v-card>
|
||||
|
@ -84,7 +124,6 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
|
@ -92,7 +131,7 @@ import RecipeRating from "./RecipeRating.vue";
|
|||
import RecipeChips from "./RecipeChips.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeFavoriteBadge,
|
||||
RecipeContextMenu,
|
||||
|
@ -139,27 +178,23 @@ export default defineComponent({
|
|||
default: false,
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
type: [Number],
|
||||
default: 150,
|
||||
},
|
||||
imageHeight: {
|
||||
type: [Number, String],
|
||||
default: "fill-height",
|
||||
},
|
||||
},
|
||||
emits: ["selected", "delete"],
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const showRecipeContent = computed(() => props.recipeId && props.slug);
|
||||
const recipeRoute = computed<string>(() => {
|
||||
return showRecipeContent.value ? `/g/${groupSlug.value}/r/${props.slug}` : "";
|
||||
});
|
||||
const cursor = computed(() => showRecipeContent.value ? "pointer" : "auto");
|
||||
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
recipeRoute,
|
||||
|
@ -170,7 +205,10 @@ export default defineComponent({
|
|||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
:deep(.v-list-item__prepend) {
|
||||
height: 100%;
|
||||
}
|
||||
.v-mobile-img {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
|
@ -198,8 +236,9 @@ export default defineComponent({
|
|||
align-self: start !important;
|
||||
}
|
||||
|
||||
.flat, .theme--dark .flat {
|
||||
box-shadow: none!important;
|
||||
background-color: transparent!important;
|
||||
.flat,
|
||||
.theme--dark .flat {
|
||||
box-shadow: none !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,67 +1,102 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-app-bar v-if="!disableToolbar" color="transparent" flat class="mt-n1 flex-sm-wrap rounded">
|
||||
<v-app-bar
|
||||
v-if="!disableToolbar"
|
||||
color="transparent"
|
||||
:absolute="false"
|
||||
flat
|
||||
class="mt-n1 flex-sm-wrap rounded position-relative w-100 left-0 top-0"
|
||||
>
|
||||
<slot name="title">
|
||||
<v-icon v-if="title" large left>
|
||||
<v-icon
|
||||
v-if="title"
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ displayTitleIcon }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ title }} </v-toolbar-title>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ title }}
|
||||
</v-toolbar-title>
|
||||
</slot>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn :icon="$vuetify.breakpoint.xsOnly" text :disabled="recipes.length === 0" @click="navigateRandom">
|
||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
:icon="$vuetify.display.xs"
|
||||
variant="text"
|
||||
:disabled="recipes.length === 0"
|
||||
@click="navigateRandom"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
{{ $globals.icons.diceMultiple }}
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.random") }}
|
||||
{{ $vuetify.display.xs ? null : $t("general.random") }}
|
||||
</v-btn>
|
||||
|
||||
<v-menu v-if="$listeners.sortRecipes" offset-y left>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn text :icon="$vuetify.breakpoint.xsOnly" v-bind="attrs" :loading="sortLoading" v-on="on">
|
||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
||||
<v-menu
|
||||
v-if="!disableSort"
|
||||
offset-y
|
||||
start
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:icon="$vuetify.display.xs"
|
||||
v-bind="props"
|
||||
:loading="sortLoading"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
{{ preferences.sortIcon }}
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : $t("general.sort") }}
|
||||
{{ $vuetify.display.xs ? null : $t("general.sort") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-item @click="sortRecipes(EVENTS.az)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.orderAlphabeticalAscending }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.orderAlphabeticalAscending }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.sort-alphabetically") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.rating)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.star }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.star }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.rating") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.created)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.newBox }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.newBox }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.created") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.updated)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.update }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.update }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.updated") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
<v-list-item @click="sortRecipes(EVENTS.lastMade)">
|
||||
<v-icon left>
|
||||
{{ $globals.icons.chefHat }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||
<div class="d-flex align-center flex-nowrap">
|
||||
<v-icon class="mr-2" inline>
|
||||
{{ $globals.icons.chefHat }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ $t("general.last-made") }}</v-list-item-title>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<ContextMenu
|
||||
v-if="!$vuetify.breakpoint.smAndDown"
|
||||
v-if="!$vuetify.display.smAndDown"
|
||||
:items="[
|
||||
{
|
||||
title: $tc('general.toggle-view'),
|
||||
title: $t('general.toggle-view'),
|
||||
icon: $globals.icons.eye,
|
||||
event: 'toggle-dense-view',
|
||||
},
|
||||
|
@ -72,84 +107,75 @@
|
|||
<div v-if="recipes && ready">
|
||||
<div class="mt-2">
|
||||
<v-row v-if="!useMobileCards">
|
||||
<v-col v-for="(recipe, index) in recipes" :key="recipe.slug + index" :sm="6" :md="6" :lg="4" :xl="3">
|
||||
<v-lazy>
|
||||
<RecipeCard
|
||||
:name="recipe.name"
|
||||
:description="recipe.description"
|
||||
:slug="recipe.slug"
|
||||
:rating="recipe.rating"
|
||||
:image="recipe.image"
|
||||
:tags="recipe.tags"
|
||||
:recipe-id="recipe.id"
|
||||
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</v-lazy>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-else dense>
|
||||
<v-col
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.name"
|
||||
:key="recipe.id!"
|
||||
:sm="6"
|
||||
:md="6"
|
||||
:lg="4"
|
||||
:xl="3"
|
||||
>
|
||||
<RecipeCard
|
||||
:name="recipe.name!"
|
||||
:description="recipe.description!"
|
||||
:slug="recipe.slug!"
|
||||
:rating="recipe.rating!"
|
||||
:image="recipe.image!"
|
||||
:tags="recipe.tags!"
|
||||
:recipe-id="recipe.id!"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row
|
||||
v-else
|
||||
dense
|
||||
>
|
||||
<v-col
|
||||
v-for="recipe in recipes"
|
||||
:key="recipe.id!"
|
||||
cols="12"
|
||||
:sm="singleColumn ? '12' : '12'"
|
||||
:md="singleColumn ? '12' : '6'"
|
||||
:lg="singleColumn ? '12' : '4'"
|
||||
:xl="singleColumn ? '12' : '3'"
|
||||
>
|
||||
<v-lazy>
|
||||
<RecipeCardMobile
|
||||
:name="recipe.name"
|
||||
:description="recipe.description"
|
||||
:slug="recipe.slug"
|
||||
:rating="recipe.rating"
|
||||
:image="recipe.image"
|
||||
:tags="recipe.tags"
|
||||
:recipe-id="recipe.id"
|
||||
|
||||
v-on="$listeners"
|
||||
/>
|
||||
</v-lazy>
|
||||
<RecipeCardMobile
|
||||
:name="recipe.name!"
|
||||
:description="recipe.description!"
|
||||
:slug="recipe.slug!"
|
||||
:rating="recipe.rating!"
|
||||
:image="recipe.image!"
|
||||
:tags="recipe.tags!"
|
||||
:recipe-id="recipe.id!"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
<v-card v-intersect="infiniteScroll"></v-card>
|
||||
<v-card v-intersect="infiniteScroll" />
|
||||
<v-fade-transition>
|
||||
<AppLoader v-if="loading" :loading="loading" />
|
||||
<AppLoader
|
||||
v-if="loading"
|
||||
:loading="loading"
|
||||
/>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
onMounted,
|
||||
reactive,
|
||||
ref,
|
||||
toRefs,
|
||||
useAsync,
|
||||
useContext,
|
||||
useRoute,
|
||||
useRouter,
|
||||
watch,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import RecipeCard from "./RecipeCard.vue";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { useUserSortPreferences } from "~/composables/use-users/preferences";
|
||||
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
|
||||
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
||||
const APPEND_RECIPES_EVENT = "appendRecipes";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCard,
|
||||
RecipeCardMobile,
|
||||
|
@ -159,6 +185,10 @@ export default defineComponent({
|
|||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSort: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
|
@ -181,6 +211,7 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const preferences = useUserSortPreferences();
|
||||
|
||||
const EVENTS = {
|
||||
|
@ -192,10 +223,11 @@ export default defineComponent({
|
|||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const { $auth, $globals, $vuetify } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards;
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
const displayTitleIcon = computed(() => {
|
||||
|
@ -207,7 +239,7 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
|
@ -259,14 +291,14 @@ export default defineComponent({
|
|||
watch(
|
||||
() => props.query,
|
||||
async (newValue: RecipeSearchQuery | undefined) => {
|
||||
const newValueString = JSON.stringify(newValue)
|
||||
const newValueString = JSON.stringify(newValue);
|
||||
if (lastQuery !== newValueString) {
|
||||
lastQuery = newValueString;
|
||||
ready.value = false;
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function initRecipes() {
|
||||
|
@ -286,29 +318,26 @@ export default defineComponent({
|
|||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsync(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
const infiniteScroll = useThrottleFn(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
|
||||
const newRecipes = await fetchRecipes();
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
if (newRecipes.length) {
|
||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
const newRecipes = await fetchRecipes();
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
if (newRecipes.length) {
|
||||
context.emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
|
||||
function sortRecipes(sortType: string) {
|
||||
async function sortRecipes(sortType: string) {
|
||||
if (state.sortLoading || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
@ -318,13 +347,14 @@ export default defineComponent({
|
|||
ascIcon: string,
|
||||
descIcon: string,
|
||||
defaultOrderDirection = "asc",
|
||||
filterNull = false
|
||||
filterNull = false,
|
||||
) {
|
||||
if (preferences.value.orderBy !== orderBy) {
|
||||
preferences.value.orderBy = orderBy;
|
||||
preferences.value.orderDirection = defaultOrderDirection;
|
||||
preferences.value.filterNull = filterNull;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
}
|
||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||
|
@ -337,7 +367,7 @@ export default defineComponent({
|
|||
$globals.icons.sortAlphabeticalAscending,
|
||||
$globals.icons.sortAlphabeticalDescending,
|
||||
"asc",
|
||||
false
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.rating:
|
||||
|
@ -349,7 +379,7 @@ export default defineComponent({
|
|||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
false
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.updated:
|
||||
|
@ -361,7 +391,7 @@ export default defineComponent({
|
|||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
true
|
||||
true,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
|
@ -369,21 +399,19 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
useAsync(async () => {
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
state.sortLoading = true;
|
||||
loading.value = true;
|
||||
state.sortLoading = true;
|
||||
loading.value = true;
|
||||
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
|
||||
state.sortLoading = false;
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
state.sortLoading = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function navigateRandom() {
|
||||
|
|
|
@ -1,13 +1,19 @@
|
|||
<template>
|
||||
<div v-if="items.length > 0">
|
||||
<h2 v-if="title" class="mt-4">{{ title }}</h2>
|
||||
<h2
|
||||
v-if="title"
|
||||
class="mt-4"
|
||||
>
|
||||
{{ title }}
|
||||
</h2>
|
||||
<v-chip
|
||||
v-for="category in items.slice(0, limit)"
|
||||
:key="category.name"
|
||||
label
|
||||
class="ma-1"
|
||||
color="accent"
|
||||
:small="small"
|
||||
variant="flat"
|
||||
:size="small ? 'small' : 'default'"
|
||||
dark
|
||||
|
||||
@click.prevent="() => $emit('item-selected', category, urlPrefix)"
|
||||
|
@ -18,12 +24,11 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
truncate: {
|
||||
type: Boolean,
|
||||
|
@ -54,13 +59,14 @@ export default defineComponent({
|
|||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["item-selected"],
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "")
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const baseRecipeRoute = computed<string>(() => {
|
||||
return `/g/${groupSlug.value}`
|
||||
return `/g/${groupSlug.value}`;
|
||||
});
|
||||
|
||||
function truncateText(text: string, length = 20, clamp = "...") {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
:title="$t('recipe.delete-recipe')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="deleteRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
|
@ -19,16 +20,17 @@
|
|||
:title="$t('recipe.duplicate')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.duplicate"
|
||||
can-confirm
|
||||
@confirm="duplicateRecipe()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="recipeName"
|
||||
dense
|
||||
density="compact"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
autofocus
|
||||
@keyup.enter="duplicateRecipe()"
|
||||
></v-text-field>
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<BaseDialog
|
||||
|
@ -36,6 +38,7 @@
|
|||
:title="$t('recipe.add-recipe-to-mealplan')"
|
||||
color="primary"
|
||||
:icon="$globals.icons.calendar"
|
||||
can-confirm
|
||||
@confirm="addRecipeToPlan()"
|
||||
>
|
||||
<v-card-text>
|
||||
|
@ -47,22 +50,21 @@
|
|||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="newMealdate"
|
||||
v-model="newMealdateString"
|
||||
:label="$t('general.date')"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
readonly
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="newMealdate"
|
||||
no-title
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="pickerMenu = false"
|
||||
@update:model-value="pickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
<v-select
|
||||
|
@ -70,7 +72,9 @@
|
|||
:return-object="false"
|
||||
:items="planTypeOptions"
|
||||
:label="$t('recipe.entry-type')"
|
||||
></v-select>
|
||||
item-title="text"
|
||||
item-value="value"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
<RecipeDialogAddToShoppingList
|
||||
|
@ -81,35 +85,53 @@
|
|||
/>
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
start
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="$vuetify.breakpoint.mdAndUp"
|
||||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn :fab="fab" :small="fab" :color="color" :icon="!fab" dark v-bind="attrs" v-on="on" @click.prevent>
|
||||
<v-icon>{{ icon }}</v-icon>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
icon
|
||||
:variant="fab ? 'flat' : undefined"
|
||||
:rounded="fab ? 'circle' : undefined"
|
||||
:size="fab ? 'small' : undefined"
|
||||
:color="fab ? 'info' : 'secondary'"
|
||||
:fab="fab"
|
||||
v-bind="props"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon
|
||||
:size="!fab ? undefined : 'x-large'"
|
||||
:color="fab ? 'white' : 'secondary'"
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<v-list density="compact">
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<v-list-item-icon>
|
||||
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<template #prepend>
|
||||
<v-icon :color="item.color">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
<div v-if="useItems.recipeActions && recipeActions && recipeActions.length">
|
||||
<v-divider />
|
||||
<v-list-group @click.stop>
|
||||
<template #activator>
|
||||
<v-list-item-title>{{ $tc("recipe.recipe-actions") }}</v-list-item-title>
|
||||
<template #activator="{ props }">
|
||||
<v-list-item-title v-bind="props">
|
||||
{{ $t("recipe.recipe-actions") }}
|
||||
</v-list-item-title>
|
||||
</template>
|
||||
<v-list dense class="ma-0 pa-0">
|
||||
<v-list density="compact" class="ma-0 pa-0">
|
||||
<v-list-item
|
||||
v-for="(action, index) in recipeActions"
|
||||
:key="index"
|
||||
|
@ -129,7 +151,6 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, useContext, useRoute, useRouter, ref } from "@nuxtjs/composition-api";
|
||||
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||
|
@ -139,15 +160,16 @@ import { useGroupRecipeActions } from "~/composables/use-group-recipe-actions";
|
|||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { usePlanTypeOptions } from "~/composables/use-group-mealplan";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { GroupRecipeActionOut, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import type { PlanEntryType } from "~/lib/api/types/meal-plan";
|
||||
import { useDownloader } from "~/composables/api/use-downloader";
|
||||
|
||||
export interface ContextMenuIncludes {
|
||||
delete: boolean;
|
||||
edit: boolean;
|
||||
download: boolean;
|
||||
duplicate: boolean;
|
||||
mealplanner: boolean;
|
||||
shoppingList: boolean;
|
||||
print: boolean;
|
||||
|
@ -164,12 +186,12 @@ export interface ContextMenuItem {
|
|||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeDialogAddToShoppingList,
|
||||
RecipeDialogPrintPreferences,
|
||||
RecipeDialogShare,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
useItems: {
|
||||
type: Object as () => ContextMenuIncludes,
|
||||
|
@ -233,6 +255,7 @@ export default defineComponent({
|
|||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["delete"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
|
@ -246,17 +269,23 @@ export default defineComponent({
|
|||
recipeName: props.name,
|
||||
loading: false,
|
||||
menuItems: [] as ContextMenuItem[],
|
||||
newMealdate: "",
|
||||
newMealdate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
newMealType: "dinner" as PlanEntryType,
|
||||
pickerMenu: false,
|
||||
});
|
||||
|
||||
const { i18n, $auth, $globals } = useContext();
|
||||
const newMealdateString = computed(() => {
|
||||
return state.newMealdate.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
|
@ -267,63 +296,63 @@ export default defineComponent({
|
|||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.tc("general.edit"),
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
isPublic: false,
|
||||
},
|
||||
delete: {
|
||||
title: i18n.tc("general.delete"),
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: undefined,
|
||||
event: "delete",
|
||||
isPublic: false,
|
||||
},
|
||||
download: {
|
||||
title: i18n.tc("general.download"),
|
||||
title: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: undefined,
|
||||
event: "download",
|
||||
isPublic: false,
|
||||
},
|
||||
duplicate: {
|
||||
title: i18n.tc("general.duplicate"),
|
||||
title: i18n.t("general.duplicate"),
|
||||
icon: $globals.icons.duplicate,
|
||||
color: undefined,
|
||||
event: "duplicate",
|
||||
isPublic: false,
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.tc("recipe.add-to-plan"),
|
||||
title: i18n.t("recipe.add-to-plan"),
|
||||
icon: $globals.icons.calendar,
|
||||
color: undefined,
|
||||
event: "mealplanner",
|
||||
isPublic: false,
|
||||
},
|
||||
shoppingList: {
|
||||
title: i18n.tc("recipe.add-to-list"),
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
print: {
|
||||
title: i18n.tc("general.print"),
|
||||
title: i18n.t("general.print"),
|
||||
icon: $globals.icons.printer,
|
||||
color: undefined,
|
||||
event: "print",
|
||||
isPublic: true,
|
||||
},
|
||||
printPreferences: {
|
||||
title: i18n.tc("general.print-preferences"),
|
||||
title: i18n.t("general.print-preferences"),
|
||||
icon: $globals.icons.printerSettings,
|
||||
color: undefined,
|
||||
event: "printPreferences",
|
||||
isPublic: true,
|
||||
},
|
||||
share: {
|
||||
title: i18n.tc("general.share"),
|
||||
title: i18n.t("general.share"),
|
||||
icon: $globals.icons.shareVariant,
|
||||
color: undefined,
|
||||
event: "share",
|
||||
|
@ -350,8 +379,10 @@ export default defineComponent({
|
|||
// Context Menu Event Handler
|
||||
|
||||
const shoppingLists = ref<ShoppingListSummary[]>();
|
||||
const recipeRef = ref<Recipe>(props.recipe);
|
||||
const recipeRefWithScale = computed(() => recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined);
|
||||
const recipeRef = ref<Recipe | undefined>(props.recipe);
|
||||
const recipeRefWithScale = computed(() =>
|
||||
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
|
||||
);
|
||||
|
||||
async function getShoppingLists() {
|
||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||
|
@ -371,13 +402,15 @@ export default defineComponent({
|
|||
const groupRecipeActionsStore = useGroupRecipeActions();
|
||||
|
||||
async function executeRecipeAction(action: GroupRecipeActionOut) {
|
||||
if (!props.recipe) return;
|
||||
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
|
||||
|
||||
if (action.actionType === "post") {
|
||||
if (!response?.error) {
|
||||
alert.success(i18n.tc("events.message-sent"));
|
||||
} else {
|
||||
alert.error(i18n.tc("events.something-went-wrong"));
|
||||
alert.success(i18n.t("events.message-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -390,7 +423,7 @@ export default defineComponent({
|
|||
context.emit("delete", props.slug);
|
||||
}
|
||||
|
||||
const download = useAxiosDownloader();
|
||||
const download = useDownloader();
|
||||
|
||||
async function handleDownloadEvent() {
|
||||
const { data } = await api.recipes.getZipToken(props.slug);
|
||||
|
@ -402,7 +435,7 @@ export default defineComponent({
|
|||
|
||||
async function addRecipeToPlan() {
|
||||
const { response } = await api.mealplans.createOne({
|
||||
date: state.newMealdate,
|
||||
date: newMealdateString.value,
|
||||
entryType: state.newMealType,
|
||||
title: "",
|
||||
text: "",
|
||||
|
@ -411,7 +444,8 @@ export default defineComponent({
|
|||
|
||||
if (response?.status === 201) {
|
||||
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
|
||||
}
|
||||
}
|
||||
|
@ -424,6 +458,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
// Note: Print is handled as an event in the parent component
|
||||
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
delete: () => {
|
||||
state.recipeDeleteDialog = true;
|
||||
|
@ -448,7 +483,9 @@ export default defineComponent({
|
|||
promises.push(refreshRecipe());
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(() => { state.shoppingListDialog = true });
|
||||
Promise.allSettled(promises).then(() => {
|
||||
state.shoppingListDialog = true;
|
||||
});
|
||||
},
|
||||
share: () => {
|
||||
state.shareDialog = true;
|
||||
|
@ -472,6 +509,7 @@ export default defineComponent({
|
|||
|
||||
return {
|
||||
...toRefs(state),
|
||||
newMealdateString,
|
||||
recipeRef,
|
||||
recipeRefWithScale,
|
||||
executeRecipeAction,
|
||||
|
|
|
@ -1,41 +1,29 @@
|
|||
<template>
|
||||
<div>
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:title="$t('data-pages.manage-aliases')"
|
||||
:icon="$globals.icons.edit"
|
||||
:submit-icon="$globals.icons.check"
|
||||
:submit-text="$tc('general.confirm')"
|
||||
@submit="saveAliases"
|
||||
@cancel="$emit('cancel')"
|
||||
>
|
||||
<BaseDialog v-model="dialog" :title="$t('data-pages.manage-aliases')" :icon="$globals.icons.edit"
|
||||
:submit-icon="$globals.icons.check" :submit-text="$t('general.confirm')" can-submit @submit="saveAliases"
|
||||
@cancel="$emit('cancel')">
|
||||
<v-card-text>
|
||||
<v-container>
|
||||
<v-row v-for="alias, i in aliases" :key="i">
|
||||
<v-col cols="10">
|
||||
<v-text-field
|
||||
v-model="alias.name"
|
||||
:label="$t('general.name')"
|
||||
:rules="[validators.required]"
|
||||
/>
|
||||
<v-text-field v-model="alias.name" :label="$t('general.name')" :rules="[validators.required]" />
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<BaseButtonGroup
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete'
|
||||
}
|
||||
]"
|
||||
@delete="deleteAlias(i)"
|
||||
/>
|
||||
<BaseButtonGroup :buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
]" @delete="deleteAlias(i)" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card-text>
|
||||
<template #custom-card-action>
|
||||
<BaseButton edit @click="createAlias">{{ $t('data-pages.create-alias') }}
|
||||
<BaseButton edit @click="createAlias">
|
||||
{{ $t('data-pages.create-alias') }}
|
||||
<template #icon>
|
||||
{{ $globals.icons.create }}
|
||||
</template>
|
||||
|
@ -46,18 +34,17 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
|
||||
export interface GenericAlias {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -66,21 +53,22 @@ export default defineComponent({
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["submit", "update:modelValue", "cancel"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
function createAlias() {
|
||||
aliases.value.push({
|
||||
"name": "",
|
||||
})
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
|
||||
function deleteAlias(index: number) {
|
||||
|
@ -97,11 +85,11 @@ export default defineComponent({
|
|||
|
||||
initAliases();
|
||||
whenever(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initAliases();
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
|
@ -111,9 +99,7 @@ export default defineComponent({
|
|||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
// @ts-ignore only applies to units
|
||||
|| alias.name === props.data.abbreviation
|
||||
// @ts-ignore only applies to units
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
|
@ -122,7 +108,7 @@ export default defineComponent({
|
|||
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
})
|
||||
});
|
||||
|
||||
aliases.value = keepAliases;
|
||||
context.emit("submit", keepAliases);
|
||||
|
@ -135,7 +121,7 @@ export default defineComponent({
|
|||
deleteAlias,
|
||||
saveAliases,
|
||||
validators,
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,60 +3,73 @@
|
|||
v-model="selected"
|
||||
item-key="id"
|
||||
show-select
|
||||
sort-by="dateAdded"
|
||||
sort-desc
|
||||
:sort-by="[{ key: 'dateAdded', order: 'desc' }]"
|
||||
:headers="headers"
|
||||
:items="recipes"
|
||||
:items-per-page="15"
|
||||
class="elevation-0"
|
||||
:loading="loading"
|
||||
@input="setValue(selected)"
|
||||
>
|
||||
<template #body.preappend>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>Hello</td>
|
||||
<td colspan="4"></td>
|
||||
</tr>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<a
|
||||
:href="`/g/${groupSlug}/r/${item.slug}`"
|
||||
style="color: inherit; text-decoration: inherit; "
|
||||
@click="$emit('click')"
|
||||
>{{ item.name }}</a>
|
||||
</template>
|
||||
<template #item.name="{ item }">
|
||||
<a :href="`/g/${groupSlug}/r/${item.slug}`" style="color: inherit; text-decoration: inherit; " @click="$emit('click')">{{ item.name }}</a>
|
||||
<template #[`item.tags`]="{ item }">
|
||||
<RecipeChip
|
||||
small
|
||||
:items="item.tags!"
|
||||
:is-category="false"
|
||||
url-prefix="tags"
|
||||
@item-selected="filterItems"
|
||||
/>
|
||||
</template>
|
||||
<template #item.tags="{ item }">
|
||||
<RecipeChip small :items="item.tags" :is-category="false" url-prefix="tags" @item-selected="filterItems" />
|
||||
<template #[`item.recipeCategory`]="{ item }">
|
||||
<RecipeChip
|
||||
small
|
||||
:items="item.recipeCategory!"
|
||||
@item-selected="filterItems"
|
||||
/>
|
||||
</template>
|
||||
<template #item.recipeCategory="{ item }">
|
||||
<RecipeChip small :items="item.recipeCategory" @item-selected="filterItems" />
|
||||
<template #[`item.tools`]="{ item }">
|
||||
<RecipeChip
|
||||
small
|
||||
:items="item.tools"
|
||||
url-prefix="tools"
|
||||
@item-selected="filterItems"
|
||||
/>
|
||||
</template>
|
||||
<template #item.tools="{ item }">
|
||||
<RecipeChip small :items="item.tools" url-prefix="tools" @item-selected="filterItems" />
|
||||
<template #[`item.userId`]="{ item }">
|
||||
<div class="d-flex align-center">
|
||||
<UserAvatar
|
||||
:user-id="item.userId!"
|
||||
:tooltip="false"
|
||||
size="40"
|
||||
/>
|
||||
<div class="pl-2">
|
||||
<span class="text-left">
|
||||
{{ getMember(item.userId!) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #item.userId="{ item }">
|
||||
<v-list-item class="justify-start">
|
||||
<UserAvatar :user-id="item.userId" :tooltip="false" size="40" />
|
||||
<v-list-item-content class="pl-2">
|
||||
<v-list-item-title class="text-left">
|
||||
{{ getMember(item.userId) }}
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #item.dateAdded="{ item }">
|
||||
{{ formatDate(item.dateAdded) }}
|
||||
<template #[`item.dateAdded`]="{ item }">
|
||||
{{ formatDate(item.dateAdded!) }}
|
||||
</template>
|
||||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, useContext, useRouter } from "@nuxtjs/composition-api";
|
||||
import UserAvatar from "../User/UserAvatar.vue";
|
||||
import RecipeChip from "./RecipeChips.vue";
|
||||
import { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { UserSummary } from "~/lib/api/types/user";
|
||||
import { RecipeTag } from "~/lib/api/types/household";
|
||||
import type { UserSummary } from "~/lib/api/types/user";
|
||||
import type { RecipeTag } from "~/lib/api/types/household";
|
||||
|
||||
const INPUT_EVENT = "input";
|
||||
const INPUT_EVENT = "update:modelValue";
|
||||
|
||||
interface ShowHeaders {
|
||||
id: boolean;
|
||||
|
@ -70,11 +83,11 @@ interface ShowHeaders {
|
|||
dateAdded: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeChip, UserAvatar },
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
modelValue: {
|
||||
type: Array as PropType<Recipe[]>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
|
@ -104,45 +117,48 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
setup(props, context) {
|
||||
const { $auth, i18n } = useContext();
|
||||
const groupSlug = $auth.user?.groupSlug;
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
function setValue(value: Recipe[]) {
|
||||
context.emit(INPUT_EVENT, value);
|
||||
}
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => context.emit(INPUT_EVENT, value),
|
||||
});
|
||||
|
||||
const headers = computed(() => {
|
||||
const hdrs = [];
|
||||
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
|
||||
|
||||
if (props.showHeaders.id) {
|
||||
hdrs.push({ text: i18n.t("general.id"), value: "id" });
|
||||
hdrs.push({ title: i18n.t("general.id"), value: "id" });
|
||||
}
|
||||
if (props.showHeaders.owner) {
|
||||
hdrs.push({ text: i18n.t("general.owner"), value: "userId", align: "center" });
|
||||
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
||||
}
|
||||
hdrs.push({ text: i18n.t("general.name"), value: "name" });
|
||||
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
||||
if (props.showHeaders.categories) {
|
||||
hdrs.push({ text: i18n.t("recipe.categories"), value: "recipeCategory" });
|
||||
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||
}
|
||||
|
||||
if (props.showHeaders.tags) {
|
||||
hdrs.push({ text: i18n.t("tag.tags"), value: "tags" });
|
||||
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ text: i18n.t("tool.tools"), value: "tools" });
|
||||
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeServings) {
|
||||
hdrs.push({ text: i18n.t("recipe.servings"), value: "recipeServings" });
|
||||
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYieldQuantity) {
|
||||
hdrs.push({ text: i18n.t("recipe.yield"), value: "recipeYieldQuantity" });
|
||||
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ text: i18n.t("recipe.yield-text"), value: "recipeYield" });
|
||||
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ text: i18n.t("general.date-added"), value: "dateAdded" });
|
||||
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||
}
|
||||
|
||||
return hdrs;
|
||||
|
@ -151,7 +167,8 @@ export default defineComponent({
|
|||
function formatDate(date: string) {
|
||||
try {
|
||||
return i18n.d(Date.parse(date), "medium");
|
||||
} catch {
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
@ -181,15 +198,15 @@ export default defineComponent({
|
|||
|
||||
function getMember(id: string) {
|
||||
if (members.value[0]) {
|
||||
return members.value.find((m) => m.id === id)?.fullName;
|
||||
return members.value.find(m => m.id === id)?.fullName;
|
||||
}
|
||||
|
||||
return i18n.t("general.none");
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
groupSlug,
|
||||
setValue,
|
||||
headers,
|
||||
formatDate,
|
||||
members,
|
||||
|
@ -197,16 +214,5 @@ export default defineComponent({
|
|||
filterItems,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
selected: [],
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.selected = val;
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,11 +1,18 @@
|
|||
<template>
|
||||
<div v-if="dialog">
|
||||
<BaseDialog v-if="shoppingListDialog && ready" v-model="dialog" :title="$t('recipe.add-to-list')" :icon="$globals.icons.cartCheck">
|
||||
<v-container v-if="!shoppingListChoices.length">
|
||||
<BasePageTitle>
|
||||
<template #title>{{ $t('shopping-list.no-shopping-lists-found') }}</template>
|
||||
</BasePageTitle>
|
||||
</v-container>
|
||||
<BaseDialog
|
||||
v-if="shoppingListDialog && ready"
|
||||
v-model="dialog"
|
||||
:title="$t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
>
|
||||
<v-container v-if="!shoppingListChoices.length">
|
||||
<BasePageTitle>
|
||||
<template #title>
|
||||
{{ $t('shopping-list.no-shopping-lists-found') }}
|
||||
</template>
|
||||
</BasePageTitle>
|
||||
</v-container>
|
||||
<v-card-text>
|
||||
<v-card
|
||||
v-for="list in shoppingListChoices"
|
||||
|
@ -21,14 +28,23 @@
|
|||
</v-card-text>
|
||||
<template #card-actions>
|
||||
<v-btn
|
||||
text
|
||||
variant="text"
|
||||
color="grey"
|
||||
@click="dialog = false"
|
||||
>
|
||||
{{ $t("general.cancel") }}
|
||||
</v-btn>
|
||||
<div class="d-flex justify-end" style="width: 100%;">
|
||||
<v-checkbox v-model="preferences.viewAllLists" hide-details :label="$tc('general.show-all')" class="my-auto mr-4" @click="setShowAllToggled()" />
|
||||
<div
|
||||
class="d-flex justify-end"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<v-checkbox
|
||||
v-model="preferences.viewAllLists"
|
||||
hide-details
|
||||
:label="$t('general.show-all')"
|
||||
class="my-auto mr-4"
|
||||
@click="setShowAllToggled()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
@ -38,32 +54,52 @@
|
|||
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
width="70%"
|
||||
:submit-text="$tc('recipe.add-to-list')"
|
||||
:submit-text="$t('recipe.add-to-list')"
|
||||
can-submit
|
||||
@submit="addRecipesToList()"
|
||||
>
|
||||
<div style="max-height: 70vh; overflow-y: auto">
|
||||
<v-card
|
||||
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections" :key="recipeSection.recipeId + recipeSectionIndex"
|
||||
v-for="(recipeSection, recipeSectionIndex) in recipeIngredientSections"
|
||||
:key="recipeSection.recipeId + recipeSectionIndex"
|
||||
elevation="0"
|
||||
height="fit-content"
|
||||
width="100%"
|
||||
>
|
||||
<v-divider v-if="recipeSectionIndex > 0" class="mt-3" />
|
||||
<v-divider
|
||||
v-if="recipeSectionIndex > 0"
|
||||
class="mt-3"
|
||||
/>
|
||||
<v-card-title
|
||||
v-if="recipeIngredientSections.length > 1"
|
||||
class="justify-center text-h5"
|
||||
width="100%"
|
||||
>
|
||||
<v-container style="width: 100%;">
|
||||
<v-row no-gutters class="ma-0 pa-0">
|
||||
<v-col cols="12" align-self="center" class="text-center">
|
||||
<v-row
|
||||
no-gutters
|
||||
class="ma-0 pa-0"
|
||||
>
|
||||
<v-col
|
||||
cols="12"
|
||||
align-self="center"
|
||||
class="text-center"
|
||||
>
|
||||
{{ recipeSection.recipeName }}
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row v-if="recipeSection.recipeScale > 1" no-gutters class="ma-0 pa-0">
|
||||
<v-row
|
||||
v-if="recipeSection.recipeScale > 1"
|
||||
no-gutters
|
||||
class="ma-0 pa-0"
|
||||
>
|
||||
<!-- TODO: make this editable in the dialog and visible on single-recipe lists -->
|
||||
<v-col cols="12" align-self="center" class="text-center">
|
||||
({{ $tc("recipe.quantity") }}: {{ recipeSection.recipeScale }})
|
||||
<v-col
|
||||
cols="12"
|
||||
align-self="center"
|
||||
class="text-center"
|
||||
>
|
||||
({{ $t("recipe.quantity") }}: {{ recipeSection.recipeScale }})
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
@ -73,36 +109,41 @@
|
|||
v-for="(ingredientSection, ingredientSectionIndex) in recipeSection.ingredientSections"
|
||||
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex"
|
||||
>
|
||||
<v-card-title v-if="ingredientSection.sectionName" class="ingredient-title mt-2 pb-0 text-h6">
|
||||
<v-card-title
|
||||
v-if="ingredientSection.sectionName"
|
||||
class="ingredient-title mt-2 pb-0 text-h6"
|
||||
>
|
||||
{{ ingredientSection.sectionName }}
|
||||
</v-card-title>
|
||||
<div
|
||||
:class="$vuetify.breakpoint.smAndDown ? '' : 'ingredient-grid'"
|
||||
:style="$vuetify.breakpoint.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
:class="$vuetify.display.smAndDown ? '' : 'ingredient-grid'"
|
||||
:style="$vuetify.display.smAndDown ? '' : { gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(ingredientData, i) in ingredientSection.ingredients"
|
||||
:key="recipeSection.recipeId + recipeSectionIndex + ingredientSectionIndex + i"
|
||||
dense
|
||||
density="compact"
|
||||
@click="recipeIngredientSections[recipeSectionIndex]
|
||||
.ingredientSections[ingredientSectionIndex]
|
||||
.ingredients[i].checked = !recipeIngredientSections[recipeSectionIndex]
|
||||
.ingredientSections[ingredientSectionIndex]
|
||||
.ingredients[i]
|
||||
.checked"
|
||||
.ingredientSections[ingredientSectionIndex]
|
||||
.ingredients[i]
|
||||
.checked"
|
||||
>
|
||||
<v-checkbox
|
||||
hide-details
|
||||
:input-value="ingredientData.checked"
|
||||
:model-value="ingredientData.checked"
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
/>
|
||||
<v-list-item-content :key="ingredientData.ingredient.quantity">
|
||||
<div :key="ingredientData.ingredient.quantity">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:disable-amount="ingredientData.disableAmount"
|
||||
:scale="recipeSection.recipeScale" />
|
||||
</v-list-item-content>
|
||||
:scale="recipeSection.recipeScale"
|
||||
/>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -114,12 +155,12 @@
|
|||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.checkboxBlankOutline,
|
||||
text: $tc('shopping-list.uncheck-all-items'),
|
||||
text: $t('shopping-list.uncheck-all-items'),
|
||||
event: 'uncheck',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.checkboxOutline,
|
||||
text: $tc('shopping-list.check-all-items'),
|
||||
text: $t('shopping-list.check-all-items'),
|
||||
event: 'check',
|
||||
},
|
||||
]"
|
||||
|
@ -132,14 +173,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, useContext, watchEffect } from "@nuxtjs/composition-api";
|
||||
import { toRefs } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useShoppingListPreferences } from "~/composables/use-users/preferences";
|
||||
import { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { RecipeIngredient, ShoppingListAddRecipeParamsBulk, ShoppingListSummary } from "~/lib/api/types/household";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export interface RecipeWithScale extends Recipe {
|
||||
scale: number;
|
||||
|
@ -163,12 +203,12 @@ export interface ShoppingListRecipeIngredientSection {
|
|||
ingredientSections: ShoppingListIngredientSection[];
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeIngredientListItem,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -181,8 +221,10 @@ export default defineComponent({
|
|||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { $auth, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
|
@ -190,10 +232,10 @@ export default defineComponent({
|
|||
// v-model support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
initState();
|
||||
},
|
||||
});
|
||||
|
@ -205,11 +247,11 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user?.householdSlug || "";
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
const shoppingListChoices = computed(() => {
|
||||
return props.shoppingLists.filter((list) => preferences.value.viewAllLists || list.userId === $auth.user?.id);
|
||||
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||
});
|
||||
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
|
@ -220,7 +262,8 @@ export default defineComponent({
|
|||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
|
@ -234,7 +277,6 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
if (recipeSectionMap.has(recipe.slug)) {
|
||||
// @ts-ignore not undefined, see above
|
||||
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
|
||||
continue;
|
||||
}
|
||||
|
@ -247,7 +289,8 @@ export default defineComponent({
|
|||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
} else if (!recipe.recipeIngredient.length) {
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
@ -257,7 +300,7 @@ export default defineComponent({
|
|||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
ingredient: ing,
|
||||
disableAmount: recipe.settings?.disableAmount || false,
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
let currentTitle = "";
|
||||
|
@ -300,7 +343,7 @@ export default defineComponent({
|
|||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||
|
@ -366,13 +409,13 @@ export default defineComponent({
|
|||
recipeId: section.recipeId,
|
||||
recipeIncrementQuantity: section.recipeScale,
|
||||
recipeIngredients: ingredients,
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||
error ? alert.error(i18n.tc("recipe.failed-to-add-recipes-to-list"))
|
||||
: alert.success(i18n.tc("recipe.successfully-added-to-list"));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
|
||||
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
|
@ -391,9 +434,9 @@ export default defineComponent({
|
|||
setShowAllToggled,
|
||||
recipeIngredientSections,
|
||||
selectedShoppingList,
|
||||
}
|
||||
};
|
||||
},
|
||||
})
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
|
|
|
@ -1,54 +1,88 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-dialog v-model="dialog" width="800">
|
||||
<template #activator="{ on, attrs }">
|
||||
<BaseButton v-bind="attrs" v-on="on" @click="inputText = inputTextProp">
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
width="800"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<BaseButton
|
||||
v-bind="props"
|
||||
@click="inputText = inputTextProp"
|
||||
>
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-app-bar dense dark color="primary" class="mb-2">
|
||||
<v-icon large left>
|
||||
<v-app-bar
|
||||
density="compact"
|
||||
dark
|
||||
color="primary"
|
||||
class="mb-2 position-relative left-0 top-0 w-100"
|
||||
>
|
||||
<v-icon
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ $t("new-recipe.bulk-add") }}</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-app-bar>
|
||||
|
||||
<v-card-text>
|
||||
<v-textarea
|
||||
v-model="inputText"
|
||||
outlined
|
||||
variant="outlined"
|
||||
rows="12"
|
||||
hide-details
|
||||
:placeholder="$t('new-recipe.paste-in-your-recipe-data-each-line-will-be-treated-as-an-item-in-a-list')"
|
||||
>
|
||||
</v-textarea>
|
||||
/>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<template v-for="(util, idx) in utilities">
|
||||
<v-list-item :key="util.id" dense class="py-1">
|
||||
<v-divider />
|
||||
<template
|
||||
v-for="(util) in utilities"
|
||||
:key="util.id"
|
||||
>
|
||||
<v-list-item
|
||||
density="compact"
|
||||
class="py-1"
|
||||
>
|
||||
<v-list-item-title>
|
||||
<v-list-item-subtitle class="wrap-word">
|
||||
{{ util.description }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-title>
|
||||
<BaseButton small color="info" @click="util.action">
|
||||
<template #icon> {{ $globals.icons.robot }}</template>
|
||||
<BaseButton
|
||||
size="small"
|
||||
color="info"
|
||||
@click="util.action"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.robot }}
|
||||
</template>
|
||||
{{ $t("general.run") }}
|
||||
</BaseButton>
|
||||
</v-list-item>
|
||||
<v-divider :key="`divider-${idx}`" class="mx-2"></v-divider>
|
||||
<v-divider class="mx-2" />
|
||||
</template>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton save color="success" @click="save"> </BaseButton>
|
||||
<BaseButton
|
||||
cancel
|
||||
@click="dialog = false"
|
||||
/>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
save
|
||||
color="success"
|
||||
@click="save"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
@ -56,8 +90,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { reactive, toRefs, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
inputTextProp: {
|
||||
type: String,
|
||||
|
@ -65,6 +98,7 @@ export default defineComponent({
|
|||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["bulk-data"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
|
@ -72,12 +106,12 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
function splitText() {
|
||||
return state.inputText.split("\n").filter((line) => !(line === "\n" || !line));
|
||||
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
|
||||
}
|
||||
|
||||
function removeFirstCharacter() {
|
||||
state.inputText = splitText()
|
||||
.map((line) => line.substring(1))
|
||||
.map(line => line.substring(1))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
|
@ -108,22 +142,22 @@ export default defineComponent({
|
|||
state.dialog = false;
|
||||
}
|
||||
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
{
|
||||
id: "trim-whitespace",
|
||||
description: i18n.tc("new-recipe.trim-whitespace-description"),
|
||||
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||
action: trimAllLines,
|
||||
},
|
||||
{
|
||||
id: "trim-prefix",
|
||||
description: i18n.tc("new-recipe.trim-prefix-description"),
|
||||
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||
action: removeFirstCharacter,
|
||||
},
|
||||
{
|
||||
id: "split-by-numbered-line",
|
||||
description: i18n.tc("new-recipe.split-by-numbered-line-description"),
|
||||
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
||||
action: splitByNumberedLine,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -2,16 +2,29 @@
|
|||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:icon="$globals.icons.printerSettings"
|
||||
:title="$tc('general.print-preferences')"
|
||||
:title="$t('general.print-preferences')"
|
||||
width="70%"
|
||||
max-width="816px"
|
||||
>
|
||||
<div class="pa-6">
|
||||
<v-container class="print-config mb-3 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="auto" align-self="center" class="text-center">
|
||||
<div class="text-subtitle-2" style="text-align: center;">{{ $tc('recipe.recipe-image') }}</div>
|
||||
<v-btn-toggle v-model="preferences.imagePosition" mandatory style="width: fit-content;">
|
||||
<v-col
|
||||
cols="auto"
|
||||
align-self="center"
|
||||
class="text-center"
|
||||
>
|
||||
<div
|
||||
class="text-subtitle-2"
|
||||
style="text-align: center;"
|
||||
>
|
||||
{{ $t('recipe.recipe-image') }}
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
v-model="preferences.imagePosition"
|
||||
mandatory="force"
|
||||
style="width: fit-content;"
|
||||
>
|
||||
<v-btn :value="ImagePosition.left">
|
||||
<v-icon>{{ $globals.icons.dockLeft }}</v-icon>
|
||||
</v-btn>
|
||||
|
@ -23,20 +36,37 @@
|
|||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</v-col>
|
||||
<v-col cols="auto" align-self="start">
|
||||
<v-col
|
||||
cols="auto"
|
||||
align-self="start"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<v-switch v-model="preferences.showDescription" hide-details :label="$tc('recipe.description')" />
|
||||
<v-switch
|
||||
v-model="preferences.showDescription"
|
||||
hide-details
|
||||
:label="$t('recipe.description')"
|
||||
/>
|
||||
</v-row>
|
||||
<v-row no-gutters>
|
||||
<v-switch v-model="preferences.showNotes" hide-details :label="$tc('recipe.notes')" />
|
||||
<v-switch
|
||||
v-model="preferences.showNotes"
|
||||
hide-details
|
||||
:label="$t('recipe.notes')"
|
||||
/>
|
||||
</v-row>
|
||||
</v-col>
|
||||
<v-col cols="auto" align-self="start">
|
||||
<v-row no-gutters>
|
||||
<v-switch v-model="preferences.showNutrition" hide-details :label="$tc('recipe.nutrition')" />
|
||||
</v-row>
|
||||
<v-col
|
||||
cols="auto"
|
||||
align-self="start"
|
||||
>
|
||||
<v-row no-gutters>
|
||||
<v-switch
|
||||
v-model="preferences.showNutrition"
|
||||
hide-details
|
||||
:label="$t('recipe.nutrition')"
|
||||
/>
|
||||
</v-row>
|
||||
<v-row no-gutters />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
@ -47,42 +77,43 @@
|
|||
class="print-preview"
|
||||
style="overflow-y: auto;"
|
||||
>
|
||||
<RecipePrintView :recipe="recipe"/>
|
||||
<RecipePrintView :recipe="recipe" />
|
||||
</v-card>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePrintView,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const preferences = useUserPrintPreferences();
|
||||
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -90,7 +121,7 @@ export default defineComponent({
|
|||
dialog,
|
||||
ImagePosition,
|
||||
preferences,
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,37 +1,61 @@
|
|||
<template>
|
||||
<div>
|
||||
<slot v-bind="{ open, close }"> </slot>
|
||||
<v-dialog v-model="dialog" max-width="988px" content-class="top-dialog" :scrollable="false">
|
||||
<v-app-bar sticky dark color="primary lighten-1" :rounded="!$vuetify.breakpoint.xs">
|
||||
<slot v-bind="{ open, close }" />
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
max-width="988px"
|
||||
content-class="top-dialog"
|
||||
:scrollable="false"
|
||||
>
|
||||
<v-app-bar
|
||||
sticky
|
||||
dark
|
||||
color="primary-lighten-1 top-0 position-relative left-0"
|
||||
:rounded="!$vuetify.display.xs"
|
||||
>
|
||||
<v-text-field
|
||||
id="arrow-search"
|
||||
v-model="search.query.value"
|
||||
autofocus
|
||||
solo
|
||||
variant="solo-filled"
|
||||
flat
|
||||
autocomplete="off"
|
||||
background-color="primary lighten-1"
|
||||
bg-color="primary-lighten-1"
|
||||
color="white"
|
||||
dense
|
||||
density="compact"
|
||||
class="mx-2 arrow-search"
|
||||
hide-details
|
||||
single-line
|
||||
:placeholder="$t('search.search')"
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
></v-text-field>
|
||||
/>
|
||||
|
||||
<v-btn v-if="$vuetify.breakpoint.xs" x-small fab light @click="dialog = false">
|
||||
<v-btn
|
||||
v-if="$vuetify.display.xs"
|
||||
size="x-small"
|
||||
class="rounded-circle"
|
||||
light
|
||||
@click="dialog = false"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.close }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-app-bar>
|
||||
<v-card class="mt-1 pa-1 scroll" max-height="700px" relative :loading="loading">
|
||||
<v-card
|
||||
class="position-relative mt-1 pa-1 scroll"
|
||||
max-height="700px"
|
||||
relative
|
||||
:loading="loading"
|
||||
>
|
||||
<v-card-actions>
|
||||
<div class="mr-auto">
|
||||
{{ $t("search.results") }}
|
||||
</div>
|
||||
<router-link :to="advancedSearchUrl"> {{ $t("search.advanced-search") }} </router-link>
|
||||
<!-- <router-link
|
||||
:to="advancedSearchUrl"
|
||||
class="text-primary"
|
||||
> {{ $t("search.advanced-search") }} </router-link> -->
|
||||
</v-card-actions>
|
||||
|
||||
<RecipeCardMobile
|
||||
|
@ -39,13 +63,13 @@
|
|||
:key="index"
|
||||
:tabindex="index"
|
||||
class="ma-1 arrow-nav"
|
||||
:name="recipe.name"
|
||||
:description="recipe.description || ''"
|
||||
:slug="recipe.slug"
|
||||
:rating="recipe.rating"
|
||||
:name="recipe.name ?? ''"
|
||||
:description="recipe.description ?? ''"
|
||||
:slug="recipe.slug ?? ''"
|
||||
:rating="recipe.rating ?? 0"
|
||||
:image="recipe.image"
|
||||
:recipe-id="recipe.id"
|
||||
v-on="$listeners.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||
:recipe-id="recipe.id ?? ''"
|
||||
v-bind="$attrs.selected ? { selected: () => handleSelect(recipe) } : {}"
|
||||
/>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
@ -53,21 +77,21 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, toRefs, reactive, ref, watch, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
||||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||
|
||||
const SELECTED_EVENT = "selected";
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCardMobile,
|
||||
},
|
||||
|
||||
setup(_, context) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
selectedIndex: -1,
|
||||
|
@ -110,13 +134,16 @@ export default defineComponent({
|
|||
if (e.key === "Enter") {
|
||||
console.log(document.activeElement);
|
||||
// (document.activeElement as HTMLElement).click();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
}
|
||||
else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
state.selectedIndex--;
|
||||
} else if (e.key === "ArrowDown") {
|
||||
}
|
||||
else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
state.selectedIndex++;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
selectRecipe();
|
||||
|
@ -125,14 +152,15 @@ export default defineComponent({
|
|||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
document.addEventListener("keyup", onUpDown);
|
||||
}
|
||||
});
|
||||
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const route = useRoute();
|
||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`)
|
||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
|
||||
watch(route, close);
|
||||
|
||||
function open() {
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<BaseDialog v-model="dialog" :title="$t('recipe-share.share-recipe')" :icon="$globals.icons.link">
|
||||
<BaseDialog
|
||||
v-model="dialog"
|
||||
:title="$t('recipe-share.share-recipe')"
|
||||
:icon="$globals.icons.link"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-menu
|
||||
v-model="datePickerMenu"
|
||||
|
@ -10,68 +14,94 @@
|
|||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="expirationDate"
|
||||
v-model="expirationDateString"
|
||||
:label="$t('recipe-share.expiration-date')"
|
||||
:hint="$t('recipe-share.default-30-days')"
|
||||
persistent-hint
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
readonly
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="expirationDate"
|
||||
no-title
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="datePickerMenu = false"
|
||||
@update:model-value="datePickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<BaseButton small @click="createNewToken"> {{ $t("general.new") }}</BaseButton>
|
||||
<BaseButton
|
||||
size="small"
|
||||
@click="createNewToken"
|
||||
>
|
||||
{{ $t("general.new") }}
|
||||
</BaseButton>
|
||||
</v-card-actions>
|
||||
|
||||
<v-list-item v-for="token in tokens" :key="token.id" @click="shareRecipe(token.id)">
|
||||
<v-list-item-avatar color="grey">
|
||||
<v-icon dark class="pa-2"> {{ $globals.icons.link }} </v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item
|
||||
v-for="token in tokens"
|
||||
:key="token.id"
|
||||
class="px-2"
|
||||
style="padding-top: 8px; padding-bottom: 8px;"
|
||||
@click="shareRecipe(token.id)"
|
||||
>
|
||||
<div class="d-flex align-center" style="width: 100%;">
|
||||
<v-avatar color="grey">
|
||||
<v-icon>
|
||||
{{ $globals.icons.link }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title> {{ $t("recipe-share.expires-at") }} </v-list-item-title>
|
||||
<div class="pl-3 flex-grow-1">
|
||||
<v-list-item-title>
|
||||
{{ $t("recipe-share.expires-at") }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle>
|
||||
{{ $d(new Date(token.expiresAt!), "long") }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
|
||||
<v-list-item-subtitle>{{ $d(new Date(token.expiresAt), "long") }}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
|
||||
<v-list-item-action>
|
||||
<v-btn icon @click.stop="deleteToken(token.id)">
|
||||
<v-icon color="error lighten-1"> {{ $globals.icons.delete }} </v-icon>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="deleteToken(token.id)"
|
||||
>
|
||||
<v-icon color="error-lighten-1">
|
||||
{{ $globals.icons.delete }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
<v-list-item-action>
|
||||
<v-btn icon @click.stop="copyTokenLink(token.id)">
|
||||
<v-icon color="info lighten-1"> {{ $globals.icons.contentCopy }} </v-icon>
|
||||
<v-btn
|
||||
icon
|
||||
variant="text"
|
||||
class="ml-2"
|
||||
@click.stop="copyTokenLink(token.id)"
|
||||
>
|
||||
<v-icon color="info-lighten-1">
|
||||
{{ $globals.icons.contentCopy }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, toRefs, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
||||
import { RecipeShareToken } from "~/lib/api/types/recipe";
|
||||
import type { RecipeShareToken } from "~/lib/api/types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -84,38 +114,43 @@ export default defineComponent({
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
datePickerMenu: false,
|
||||
expirationDate: "",
|
||||
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
tokens: [] as RecipeShareToken[],
|
||||
});
|
||||
|
||||
const expirationDateString = computed(() => {
|
||||
return state.expirationDate.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// Set expiration date to today + 30 Days
|
||||
const today = new Date();
|
||||
const expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
state.expirationDate = expirationDate.toISOString().substring(0, 10);
|
||||
state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
refreshTokens();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { $auth, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
|
@ -128,11 +163,9 @@ export default defineComponent({
|
|||
|
||||
async function createNewToken() {
|
||||
// Convert expiration date to timestamp
|
||||
const expirationDate = new Date(state.expirationDate);
|
||||
|
||||
const { data } = await userApi.recipes.share.createOne({
|
||||
recipeId: props.recipeId,
|
||||
expiresAt: expirationDate.toISOString(),
|
||||
expiresAt: state.expirationDate.toISOString(),
|
||||
});
|
||||
|
||||
if (data) {
|
||||
|
@ -142,7 +175,7 @@ export default defineComponent({
|
|||
|
||||
async function deleteToken(id: string) {
|
||||
await userApi.recipes.share.deleteOne(id);
|
||||
state.tokens = state.tokens.filter((token) => token.id !== id);
|
||||
state.tokens = state.tokens.filter(token => token.id !== id);
|
||||
}
|
||||
|
||||
async function refreshTokens() {
|
||||
|
@ -187,13 +220,15 @@ export default defineComponent({
|
|||
url: getTokenLink(token),
|
||||
text: getRecipeText() as string,
|
||||
});
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
await copyTokenLink(token);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
expirationDateString,
|
||||
dialog,
|
||||
createNewToken,
|
||||
deleteToken,
|
||||
|
|
|
@ -1,16 +1,22 @@
|
|||
<template>
|
||||
<v-container fluid class="pa-0">
|
||||
<div class="search-container py-8">
|
||||
<form class="search-box pa-2" @submit.prevent="search">
|
||||
<div class="d-flex justify-center my-2">
|
||||
<v-container
|
||||
fluid
|
||||
class="pa-0"
|
||||
>
|
||||
<div class="search-container pb-8">
|
||||
<form
|
||||
class="search-box pa-2"
|
||||
@submit.prevent="search"
|
||||
>
|
||||
<div class="d-flex justify-center mb-2">
|
||||
<v-text-field
|
||||
ref="input"
|
||||
v-model="state.search"
|
||||
outlined
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
color="primary"
|
||||
:placeholder="$tc('search.search-placeholder')"
|
||||
:placeholder="$t('search.search-placeholder')"
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
@keyup.enter="hideKeyboard"
|
||||
/>
|
||||
|
@ -20,134 +26,184 @@
|
|||
<SearchFilter
|
||||
v-if="categories"
|
||||
v-model="selectedCategories"
|
||||
:require-all.sync="state.requireAllCategories"
|
||||
v-model:require-all="state.requireAllCategories"
|
||||
:items="categories"
|
||||
>
|
||||
<v-icon left>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.categories }}
|
||||
</v-icon>
|
||||
{{ $t("category.categories") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tag Filter -->
|
||||
<SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags">
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="tags"
|
||||
v-model="selectedTags"
|
||||
v-model:require-all="state.requireAllTags"
|
||||
:items="tags"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.tags }}
|
||||
</v-icon>
|
||||
{{ $t("tag.tags") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Tool Filter -->
|
||||
<SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools">
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="tools"
|
||||
v-model="selectedTools"
|
||||
v-model:require-all="state.requireAllTools"
|
||||
:items="tools"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
{{ $t("tool.tools") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Food Filter -->
|
||||
<SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods">
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="foods"
|
||||
v-model="selectedFoods"
|
||||
v-model:require-all="state.requireAllFoods"
|
||||
:items="foods"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.foods }}
|
||||
</v-icon>
|
||||
{{ $t("general.foods") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Household Filter -->
|
||||
<SearchFilter v-if="households.length > 1" v-model="selectedHouseholds" :items="households" radio>
|
||||
<v-icon left>
|
||||
<SearchFilter
|
||||
v-if="households.length > 1"
|
||||
v-model="selectedHouseholds"
|
||||
:items="households"
|
||||
radio
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.household }}
|
||||
</v-icon>
|
||||
{{ $t("household.households") }}
|
||||
</SearchFilter>
|
||||
|
||||
<!-- Sort Options -->
|
||||
<v-menu offset-y nudge-bottom="3">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn class="ml-auto" small color="accent" v-bind="attrs" v-on="on">
|
||||
<v-icon :left="!$vuetify.breakpoint.xsOnly">
|
||||
<v-menu
|
||||
offset-y
|
||||
nudge-bottom="3"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
class="ml-auto"
|
||||
size="small"
|
||||
color="accent"
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
|
||||
</v-icon>
|
||||
{{ $vuetify.breakpoint.xsOnly ? null : sortText }}
|
||||
{{ $vuetify.display.xs ? null : sortText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item @click="toggleOrderDirection()">
|
||||
<v-icon left>
|
||||
{{
|
||||
state.orderDirection === "asc" ?
|
||||
$globals.icons.sortDescending : $globals.icons.sortAscending
|
||||
}}
|
||||
</v-icon>
|
||||
<v-list-item-title>
|
||||
{{ state.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="state.orderDirection === 'asc' ? $globals.icons.sortDescending : $globals.icons.sortAscending"
|
||||
:title="state.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||
@click="toggleOrderDirection()"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
v-for="v in sortable"
|
||||
:key="v.name"
|
||||
:input-value="state.orderBy === v.value"
|
||||
:active="state.orderBy === v.value"
|
||||
slim
|
||||
density="comfortable"
|
||||
:prepend-icon="v.icon"
|
||||
:title="v.name"
|
||||
@click="state.orderBy = v.value"
|
||||
>
|
||||
<v-icon left>
|
||||
{{ v.icon }}
|
||||
</v-icon>
|
||||
<v-list-item-title>{{ v.name }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
/>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- Settings -->
|
||||
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
|
||||
<v-icon small>
|
||||
<v-menu
|
||||
offset-y
|
||||
bottom
|
||||
start
|
||||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
size="small"
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon size="small">
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-card-text>
|
||||
<v-switch v-model="state.auto" :label="$t('search.auto-search')" single-line></v-switch>
|
||||
<v-btn block color="primary" @click="reset">
|
||||
{{ $tc("general.reset") }}
|
||||
<v-switch
|
||||
v-model="state.auto"
|
||||
:label="$t('search.auto-search')"
|
||||
single-line
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
color="primary"
|
||||
@click="reset"
|
||||
>
|
||||
{{ $t("general.reset") }}
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
<div v-if="!state.auto" class="search-button-container">
|
||||
<v-btn x-large color="primary" type="submit" block>
|
||||
<v-icon left>
|
||||
<div
|
||||
v-if="!state.auto"
|
||||
class="search-button-container"
|
||||
>
|
||||
<v-btn
|
||||
size="x-large"
|
||||
color="primary"
|
||||
type="submit"
|
||||
block
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.search }}
|
||||
</v-icon>
|
||||
{{ $tc("search.search") }}
|
||||
{{ $t("search.search") }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<v-divider></v-divider>
|
||||
<v-divider />
|
||||
<v-container class="mt-6 px-md-6">
|
||||
<RecipeCardSection
|
||||
v-if="state.ready"
|
||||
class="mt-n5"
|
||||
:icon="$globals.icons.silverwareForkKnife"
|
||||
:title="$tc('general.recipes')"
|
||||
:title="$t('general.recipes')"
|
||||
:recipes="recipes"
|
||||
:query="passedQueryWithSeed"
|
||||
disable-sort
|
||||
@item-selected="filterItems"
|
||||
@replaceRecipes="replaceRecipes"
|
||||
@appendRecipes="appendRecipes"
|
||||
@replace-recipes="replaceRecipes"
|
||||
@append-recipes="appendRecipes"
|
||||
/>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref, useRoute, watch } from "@nuxtjs/composition-api";
|
||||
import { watchDebounced } from "@vueuse/shared";
|
||||
import SearchFilter from "~/components/Domain/SearchFilter.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
@ -165,17 +221,19 @@ import {
|
|||
} from "~/composables/store";
|
||||
import { useUserSearchQuerySession } from "~/composables/use-users/preferences";
|
||||
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
|
||||
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useLazyRecipes } from "~/composables/recipes";
|
||||
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { SearchFilter, RecipeCardSection },
|
||||
setup() {
|
||||
const router = useRouter();
|
||||
const { $auth, $globals, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const state = ref({
|
||||
|
@ -193,7 +251,7 @@ export default defineComponent({
|
|||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const searchQuerySession = useUserSearchQuerySession();
|
||||
|
||||
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
|
@ -236,9 +294,9 @@ export default defineComponent({
|
|||
const passedQueryWithSeed = computed(() => {
|
||||
return {
|
||||
...passedQuery.value,
|
||||
_searchSeed: Date.now().toString()
|
||||
_searchSeed: Date.now().toString(),
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
const queryDefaults = {
|
||||
search: "",
|
||||
|
@ -248,7 +306,7 @@ export default defineComponent({
|
|||
requireAllTags: false,
|
||||
requireAllTools: false,
|
||||
requireAllFoods: false,
|
||||
}
|
||||
};
|
||||
|
||||
function reset() {
|
||||
state.value.search = queryDefaults.search;
|
||||
|
@ -271,11 +329,11 @@ export default defineComponent({
|
|||
|
||||
function toIDArray(array: { id: string }[]) {
|
||||
// we sort the array to make sure the query is always the same
|
||||
return array.map((item) => item.id).sort();
|
||||
return array.map(item => item.id).sort();
|
||||
}
|
||||
|
||||
function hideKeyboard() {
|
||||
input.value.blur()
|
||||
input.value.blur();
|
||||
}
|
||||
|
||||
const input: Ref<any> = ref(null);
|
||||
|
@ -306,7 +364,7 @@ export default defineComponent({
|
|||
requireAllTools: passedQuery.value.requireAllTools ? "true" : undefined,
|
||||
requireAllFoods: passedQuery.value.requireAllFoods ? "true" : undefined,
|
||||
},
|
||||
}
|
||||
};
|
||||
await router.push({ query });
|
||||
searchQuerySession.value.recipe = JSON.stringify(query);
|
||||
}
|
||||
|
@ -314,7 +372,7 @@ export default defineComponent({
|
|||
function waitUntilAndExecute(
|
||||
condition: () => boolean,
|
||||
callback: () => void,
|
||||
opts = { timeout: 2000, interval: 500 }
|
||||
opts = { timeout: 2000, interval: 500 },
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const state = {
|
||||
|
@ -341,7 +399,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
const sortText = computed(() => {
|
||||
const sort = sortable.find((s) => s.value === state.value.orderBy);
|
||||
const sort = sortable.find(s => s.value === state.value.orderBy);
|
||||
if (!sort) return "";
|
||||
return `${sort.name}`;
|
||||
});
|
||||
|
@ -349,103 +407,112 @@ export default defineComponent({
|
|||
const sortable = [
|
||||
{
|
||||
icon: $globals.icons.orderAlphabeticalAscending,
|
||||
name: i18n.tc("general.sort-alphabetically"),
|
||||
name: i18n.t("general.sort-alphabetically"),
|
||||
value: "name",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.newBox,
|
||||
name: i18n.tc("general.created"),
|
||||
name: i18n.t("general.created"),
|
||||
value: "created_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.chefHat,
|
||||
name: i18n.tc("general.last-made"),
|
||||
name: i18n.t("general.last-made"),
|
||||
value: "last_made",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.star,
|
||||
name: i18n.tc("general.rating"),
|
||||
name: i18n.t("general.rating"),
|
||||
value: "rating",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.update,
|
||||
name: i18n.tc("general.updated"),
|
||||
name: i18n.t("general.updated"),
|
||||
value: "updated_at",
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.diceMultiple,
|
||||
name: i18n.tc("general.random"),
|
||||
name: i18n.t("general.random"),
|
||||
value: "random",
|
||||
},
|
||||
];
|
||||
|
||||
watch(
|
||||
() => route.value.query,
|
||||
() => route.query,
|
||||
() => {
|
||||
if (!Object.keys(route.value.query).length) {
|
||||
if (!Object.keys(route.query).length) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function filterItems(item: RecipeCategory | RecipeTag | RecipeTool, urlPrefix: string) {
|
||||
if (urlPrefix === "categories") {
|
||||
const result = categories.store.value.filter((category) => (category.id as string).includes(item.id as string));
|
||||
const result = categories.store.value.filter(category => (category.id as string).includes(item.id as string));
|
||||
selectedCategories.value = result as NoUndefinedField<RecipeTag>[];
|
||||
} else if (urlPrefix === "tags") {
|
||||
const result = tags.store.value.filter((tag) => (tag.id as string).includes(item.id as string));
|
||||
}
|
||||
else if (urlPrefix === "tags") {
|
||||
const result = tags.store.value.filter(tag => (tag.id as string).includes(item.id as string));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
} else if (urlPrefix === "tools") {
|
||||
const result = tools.store.value.filter((tool) => (tool.id ).includes(item.id || "" ));
|
||||
}
|
||||
else if (urlPrefix === "tools") {
|
||||
const result = tools.store.value.filter(tool => (tool.id).includes(item.id || ""));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
}
|
||||
}
|
||||
|
||||
async function hydrateSearch() {
|
||||
const query = router.currentRoute.query;
|
||||
const query = router.currentRoute.value.query;
|
||||
if (query.auto?.length) {
|
||||
state.value.auto = query.auto === "true";
|
||||
}
|
||||
|
||||
if (query.search?.length) {
|
||||
state.value.search = query.search as string;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.search = queryDefaults.search;
|
||||
}
|
||||
|
||||
if (query.orderBy?.length) {
|
||||
state.value.orderBy = query.orderBy as string;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.orderBy = queryDefaults.orderBy;
|
||||
}
|
||||
|
||||
if (query.orderDirection?.length) {
|
||||
state.value.orderDirection = query.orderDirection as "asc" | "desc";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.orderDirection = queryDefaults.orderDirection;
|
||||
}
|
||||
|
||||
if (query.requireAllCategories?.length) {
|
||||
state.value.requireAllCategories = query.requireAllCategories === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllCategories = queryDefaults.requireAllCategories;
|
||||
}
|
||||
|
||||
if (query.requireAllTags?.length) {
|
||||
state.value.requireAllTags = query.requireAllTags === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTags = queryDefaults.requireAllTags;
|
||||
}
|
||||
|
||||
if (query.requireAllTools?.length) {
|
||||
state.value.requireAllTools = query.requireAllTools === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllTools = queryDefaults.requireAllTools;
|
||||
}
|
||||
|
||||
if (query.requireAllFoods?.length) {
|
||||
state.value.requireAllFoods = query.requireAllFoods === "true";
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.value.requireAllFoods = queryDefaults.requireAllFoods;
|
||||
}
|
||||
|
||||
|
@ -456,15 +523,16 @@ export default defineComponent({
|
|||
waitUntilAndExecute(
|
||||
() => categories.store.value.length > 0,
|
||||
() => {
|
||||
const result = categories.store.value.filter((item) =>
|
||||
(query.categories as string[]).includes(item.id as string)
|
||||
const result = categories.store.value.filter(item =>
|
||||
(query.categories as string[]).includes(item.id as string),
|
||||
);
|
||||
|
||||
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedCategories.value = [];
|
||||
}
|
||||
|
||||
|
@ -473,12 +541,13 @@ export default defineComponent({
|
|||
waitUntilAndExecute(
|
||||
() => tags.store.value.length > 0,
|
||||
() => {
|
||||
const result = tags.store.value.filter((item) => (query.tags as string[]).includes(item.id as string));
|
||||
const result = tags.store.value.filter(item => (query.tags as string[]).includes(item.id as string));
|
||||
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedTags.value = [];
|
||||
}
|
||||
|
||||
|
@ -487,12 +556,13 @@ export default defineComponent({
|
|||
waitUntilAndExecute(
|
||||
() => tools.store.value.length > 0,
|
||||
() => {
|
||||
const result = tools.store.value.filter((item) => (query.tools as string[]).includes(item.id));
|
||||
const result = tools.store.value.filter(item => (query.tools as string[]).includes(item.id));
|
||||
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedTools.value = [];
|
||||
}
|
||||
|
||||
|
@ -506,12 +576,13 @@ export default defineComponent({
|
|||
return false;
|
||||
},
|
||||
() => {
|
||||
const result = foods.store.value?.filter((item) => (query.foods as string[]).includes(item.id));
|
||||
const result = foods.store.value?.filter(item => (query.foods as string[]).includes(item.id));
|
||||
selectedFoods.value = result ?? [];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedFoods.value = [];
|
||||
}
|
||||
|
||||
|
@ -525,12 +596,13 @@ export default defineComponent({
|
|||
return false;
|
||||
},
|
||||
() => {
|
||||
const result = households.store.value?.filter((item) => (query.households as string[]).includes(item.id));
|
||||
const result = households.store.value?.filter(item => (query.households as string[]).includes(item.id));
|
||||
selectedHouseholds.value = result as NoUndefinedField<HouseholdSummary>[] ?? [];
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
selectedHouseholds.value = [];
|
||||
}
|
||||
|
||||
|
@ -539,11 +611,12 @@ export default defineComponent({
|
|||
|
||||
onMounted(async () => {
|
||||
// restore the user's last search query
|
||||
if (searchQuerySession.value.recipe && !(Object.keys(route.value.query).length > 0)) {
|
||||
if (searchQuerySession.value.recipe && !(Object.keys(route.query).length > 0)) {
|
||||
try {
|
||||
const query = JSON.parse(searchQuerySession.value.recipe);
|
||||
await router.replace({ query });
|
||||
} catch (error) {
|
||||
}
|
||||
catch {
|
||||
searchQuerySession.value.recipe = "";
|
||||
router.replace({ query: {} });
|
||||
}
|
||||
|
@ -576,7 +649,7 @@ export default defineComponent({
|
|||
},
|
||||
{
|
||||
debounce: 500,
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -610,7 +683,6 @@ export default defineComponent({
|
|||
filterItems,
|
||||
};
|
||||
},
|
||||
head: {},
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,17 +1,25 @@
|
|||
<template>
|
||||
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-tooltip
|
||||
location="bottom"
|
||||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-if="isFavorite || showAlways"
|
||||
small
|
||||
icon
|
||||
:variant="buttonStyle ? 'flat' : undefined"
|
||||
:rounded="buttonStyle ? 'circle' : undefined"
|
||||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:icon="!buttonStyle"
|
||||
:fab="buttonStyle"
|
||||
v-bind="attrs"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
@click.prevent="toggleFavorite"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'">
|
||||
<v-icon
|
||||
:size="!buttonStyle ? undefined : 'x-large'"
|
||||
:color="buttonStyle ? 'white' : 'secondary'"
|
||||
>
|
||||
{{ isFavorite ? $globals.icons.heart : $globals.icons.heartOutline }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
|
@ -21,11 +29,10 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { UserOut } from "~/lib/api/types/user";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipeId: {
|
||||
type: String,
|
||||
|
@ -42,22 +49,21 @@ export default defineComponent({
|
|||
},
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||
|
||||
// TODO Setup the correct type for $auth.user
|
||||
// See https://github.com/nuxt-community/auth-module/issues/1097
|
||||
const user = computed(() => $auth.user as unknown as UserOut);
|
||||
const isFavorite = computed(() => {
|
||||
const rating = userRatings.value.find((r) => r.recipeId === props.recipeId);
|
||||
const rating = userRatings.value.find(r => r.recipeId === props.recipeId);
|
||||
return rating?.isFavorite || false;
|
||||
});
|
||||
|
||||
async function toggleFavorite() {
|
||||
if (!$auth.user.value) return;
|
||||
if (!isFavorite.value) {
|
||||
await api.users.addFavorite(user.value?.id, props.recipeId);
|
||||
} else {
|
||||
await api.users.removeFavorite(user.value?.id, props.recipeId);
|
||||
await api.users.addFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
|
|
@ -1,9 +1,19 @@
|
|||
<template>
|
||||
<div class="text-center">
|
||||
<v-menu v-model="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>
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
offset-y
|
||||
top
|
||||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.fileImage }}
|
||||
</v-icon>
|
||||
{{ $t("general.image") }}
|
||||
|
@ -25,9 +35,21 @@
|
|||
</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="messages">
|
||||
<v-text-field
|
||||
v-model="url"
|
||||
:label="$t('general.url')"
|
||||
class="pt-5"
|
||||
clearable
|
||||
:messages="messages"
|
||||
>
|
||||
<template #append-outer>
|
||||
<v-btn class="ml-2" color="primary" :loading="loading" :disabled="!slug" @click="getImageFromURL">
|
||||
<v-btn
|
||||
class="ml-2"
|
||||
color="primary"
|
||||
:loading="loading"
|
||||
:disabled="!slug"
|
||||
@click="getImageFromURL"
|
||||
>
|
||||
{{ $t("general.get") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
@ -40,13 +62,12 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const REFRESH_EVENT = "refresh";
|
||||
const UPLOAD_EVENT = "upload";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
|
@ -58,7 +79,7 @@ export default defineComponent({
|
|||
url: "",
|
||||
loading: false,
|
||||
menu: false,
|
||||
})
|
||||
});
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
context.emit(UPLOAD_EVENT, fileObject);
|
||||
|
@ -75,7 +96,7 @@ export default defineComponent({
|
|||
state.menu = false;
|
||||
}
|
||||
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,101 +1,148 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-text-field
|
||||
v-if="value.title || showTitle"
|
||||
v-model="value.title"
|
||||
dense
|
||||
v-if="model.title || showTitle"
|
||||
v-model="model.title"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
hide-details
|
||||
class="mx-1 mt-3 mb-4"
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
style="max-width: 500px"
|
||||
@click="$emit('clickIngredientField', 'title')"
|
||||
/>
|
||||
<v-row
|
||||
:no-gutters="mdAndUp"
|
||||
dense
|
||||
class="d-flex flex-wrap my-1"
|
||||
>
|
||||
</v-text-field>
|
||||
<v-row :no-gutters="$vuetify.breakpoint.mdAndUp" dense class="d-flex flex-wrap my-1">
|
||||
<v-col v-if="!disableAmount" sm="12" md="2" cols="12" class="flex-grow-0 flex-shrink-0">
|
||||
<v-col
|
||||
v-if="!disableAmount"
|
||||
sm="12"
|
||||
md="2"
|
||||
cols="12"
|
||||
class="flex-grow-0 flex-shrink-0"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="value.quantity"
|
||||
solo
|
||||
v-model="model.quantity"
|
||||
variant="solo"
|
||||
hide-details
|
||||
dense
|
||||
density="compact"
|
||||
type="number"
|
||||
:placeholder="$t('recipe.quantity')"
|
||||
@keypress="quantityFilter"
|
||||
>
|
||||
<v-icon v-if="$listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
class="mr-n1 handle"
|
||||
>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
<v-col v-if="!disableAmount" sm="12" md="3" cols="12">
|
||||
<v-col
|
||||
v-if="!disableAmount"
|
||||
sm="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
>
|
||||
<v-autocomplete
|
||||
ref="unitAutocomplete"
|
||||
v-model="value.unit"
|
||||
:search-input.sync="unitSearch"
|
||||
v-model="model.unit"
|
||||
v-model:search="unitSearch"
|
||||
auto-select-first
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="units || []"
|
||||
item-text="name"
|
||||
item-title="name"
|
||||
class="mx-1"
|
||||
:placeholder="$t('recipe.choose-unit')"
|
||||
clearable
|
||||
@keyup.enter="handleUnitEnter"
|
||||
>
|
||||
<template #no-data>
|
||||
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div>
|
||||
<div class="caption text-center pb-2">
|
||||
{{ $t("recipe.press-enter-to-create") }}
|
||||
</div>
|
||||
</template>
|
||||
<template #append-item>
|
||||
<div class="px-2">
|
||||
<BaseButton block small @click="createAssignUnit()"></BaseButton>
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@click="createAssignUnit()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</v-col>
|
||||
|
||||
<!-- Foods Input -->
|
||||
<v-col v-if="!disableAmount" m="12" md="3" cols="12" class="">
|
||||
<v-col
|
||||
v-if="!disableAmount"
|
||||
m="12"
|
||||
md="3"
|
||||
cols="12"
|
||||
class=""
|
||||
>
|
||||
<v-autocomplete
|
||||
ref="foodAutocomplete"
|
||||
v-model="value.food"
|
||||
:search-input.sync="foodSearch"
|
||||
v-model="model.food"
|
||||
v-model:search="foodSearch"
|
||||
auto-select-first
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
density="compact"
|
||||
variant="solo"
|
||||
return-object
|
||||
:items="foods || []"
|
||||
item-text="name"
|
||||
item-title="name"
|
||||
class="mx-1 py-0"
|
||||
:placeholder="$t('recipe.choose-food')"
|
||||
clearable
|
||||
@keyup.enter="handleFoodEnter"
|
||||
>
|
||||
<template #no-data>
|
||||
<div class="caption text-center pb-2">{{ $t("recipe.press-enter-to-create") }}</div>
|
||||
<div class="caption text-center pb-2">
|
||||
{{ $t("recipe.press-enter-to-create") }}
|
||||
</div>
|
||||
</template>
|
||||
<template #append-item>
|
||||
<div class="px-2">
|
||||
<BaseButton block small @click="createAssignFood()"></BaseButton>
|
||||
<BaseButton
|
||||
block
|
||||
size="small"
|
||||
@click="createAssignFood()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</v-col>
|
||||
<v-col sm="12" md="" cols="12">
|
||||
<v-col
|
||||
sm="12"
|
||||
md=""
|
||||
cols="12"
|
||||
>
|
||||
<div class="d-flex">
|
||||
<v-text-field
|
||||
v-model="value.note"
|
||||
v-model="model.note"
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
density="compact"
|
||||
variant="solo"
|
||||
:placeholder="$t('recipe.notes')"
|
||||
class="mb-auto"
|
||||
@click="$emit('clickIngredientField', 'note')"
|
||||
>
|
||||
<v-icon v-if="disableAmount && $listeners && $listeners.delete" slot="prepend" class="mr-n1 handle">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
v-if="disableAmount && $attrs && $attrs.delete"
|
||||
class="mr-n1 handle"
|
||||
>
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<BaseButtonGroup
|
||||
hover
|
||||
|
@ -112,195 +159,181 @@
|
|||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<p v-if="showOriginalText" class="text-caption">
|
||||
{{ $t("recipe.original-text-with-value", { originalText: value.originalText }) }}
|
||||
<p
|
||||
v-if="showOriginalText"
|
||||
class="text-caption"
|
||||
>
|
||||
{{ $t("recipe.original-text-with-value", { originalText: model.originalText }) }}
|
||||
</p>
|
||||
|
||||
<v-divider v-if="!$vuetify.breakpoint.mdAndUp" class="my-4"></v-divider>
|
||||
<v-divider
|
||||
v-if="!mdAndUp"
|
||||
class="my-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, toRefs } from "vue";
|
||||
import { useDisplay } from "vuetify";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFoodStore, useFoodData, useUnitStore, useUnitData } from "~/composables/store";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import { useNuxtApp } from "#app";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object as () => RecipeIngredient,
|
||||
required: true,
|
||||
},
|
||||
disableAmount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowInsertIngredient: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
// defineModel replaces modelValue prop
|
||||
const model = defineModel<RecipeIngredient>({ required: true });
|
||||
|
||||
const props = defineProps({
|
||||
disableAmount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
setup(props, { listeners }) {
|
||||
const { i18n, $globals } = useContext();
|
||||
|
||||
const contextMenuOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
text: i18n.tc("recipe.toggle-section"),
|
||||
event: "toggle-section",
|
||||
},
|
||||
{
|
||||
text: i18n.tc("recipe.insert-above"),
|
||||
event: "insert-above",
|
||||
},
|
||||
{
|
||||
text: i18n.tc("recipe.insert-below"),
|
||||
event: "insert-below",
|
||||
},
|
||||
];
|
||||
|
||||
if (props.allowInsertIngredient) {
|
||||
options.push({
|
||||
text: i18n.tc("recipe.insert-ingredient") ,
|
||||
event: "insert-ingredient",
|
||||
})
|
||||
}
|
||||
|
||||
// FUTURE: add option to parse a single ingredient
|
||||
// if (!value.food && !value.unit && value.note) {
|
||||
// options.push({
|
||||
// text: "Parse Ingredient",
|
||||
// event: "parse-ingredient",
|
||||
// });
|
||||
// }
|
||||
|
||||
if (props.value.originalText) {
|
||||
options.push({
|
||||
text: i18n.tc("recipe.see-original-text"),
|
||||
event: "toggle-original",
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const btns = computed(() => {
|
||||
const out = [
|
||||
{
|
||||
icon: $globals.icons.dotsVertical,
|
||||
text: i18n.tc("general.menu"),
|
||||
event: "open",
|
||||
children: contextMenuOptions.value,
|
||||
},
|
||||
];
|
||||
|
||||
if (listeners && listeners.delete) {
|
||||
// @ts-expect-error - TODO: fix this
|
||||
out.unshift({
|
||||
icon: $globals.icons.delete,
|
||||
text: i18n.tc("general.delete"),
|
||||
event: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// ==================================================
|
||||
// Foods
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
const foodSearch = ref("");
|
||||
const foodAutocomplete = ref<HTMLInputElement>();
|
||||
|
||||
async function createAssignFood() {
|
||||
foodData.data.name = foodSearch.value;
|
||||
props.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
||||
foodData.reset();
|
||||
foodAutocomplete.value?.blur();
|
||||
}
|
||||
|
||||
// ==================================================
|
||||
// Units
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
const unitSearch = ref("");
|
||||
const unitAutocomplete = ref<HTMLInputElement>();
|
||||
|
||||
async function createAssignUnit() {
|
||||
unitsData.data.name = unitSearch.value;
|
||||
props.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
||||
unitsData.reset();
|
||||
unitAutocomplete.value?.blur();
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
showTitle: false,
|
||||
showOriginalText: false,
|
||||
});
|
||||
|
||||
function toggleTitle() {
|
||||
if (state.showTitle) {
|
||||
props.value.title = "";
|
||||
}
|
||||
state.showTitle = !state.showTitle;
|
||||
}
|
||||
|
||||
function toggleOriginalText() {
|
||||
state.showOriginalText = !state.showOriginalText;
|
||||
}
|
||||
|
||||
function handleUnitEnter() {
|
||||
if (
|
||||
props.value.unit === undefined ||
|
||||
props.value.unit === null ||
|
||||
!props.value.unit.name.includes(unitSearch.value)
|
||||
) {
|
||||
createAssignUnit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFoodEnter() {
|
||||
if (
|
||||
props.value.food === undefined ||
|
||||
props.value.food === null ||
|
||||
!props.value.food.name.includes(foodSearch.value)
|
||||
) {
|
||||
createAssignFood();
|
||||
}
|
||||
}
|
||||
|
||||
function quantityFilter(e: KeyboardEvent) {
|
||||
// if digit is pressed, add to quantity
|
||||
if (e.key === "-" || e.key === "+" || e.key === "e") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
quantityFilter,
|
||||
toggleOriginalText,
|
||||
contextMenuOptions,
|
||||
handleUnitEnter,
|
||||
handleFoodEnter,
|
||||
foodAutocomplete,
|
||||
createAssignFood,
|
||||
unitAutocomplete,
|
||||
createAssignUnit,
|
||||
foods: foodStore.store,
|
||||
foodSearch,
|
||||
toggleTitle,
|
||||
unitActions: unitStore.actions,
|
||||
units: unitStore.store,
|
||||
unitSearch,
|
||||
validators,
|
||||
workingUnitData: unitsData.data,
|
||||
btns,
|
||||
};
|
||||
allowInsertIngredient: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
"clickIngredientField",
|
||||
"insert-above",
|
||||
"insert-below",
|
||||
"insert-ingredient",
|
||||
"delete",
|
||||
]);
|
||||
|
||||
const { mdAndUp } = useDisplay();
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const state = reactive({
|
||||
showTitle: false,
|
||||
showOriginalText: false,
|
||||
});
|
||||
|
||||
const contextMenuOptions = computed(() => {
|
||||
const options = [
|
||||
{
|
||||
text: i18n.t("recipe.toggle-section"),
|
||||
event: "toggle-section",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.insert-above"),
|
||||
event: "insert-above",
|
||||
},
|
||||
{
|
||||
text: i18n.t("recipe.insert-below"),
|
||||
event: "insert-below",
|
||||
},
|
||||
];
|
||||
|
||||
if (props.allowInsertIngredient) {
|
||||
options.push({
|
||||
text: i18n.t("recipe.insert-ingredient"),
|
||||
event: "insert-ingredient",
|
||||
});
|
||||
}
|
||||
|
||||
if (model.value.originalText) {
|
||||
options.push({
|
||||
text: i18n.t("recipe.see-original-text"),
|
||||
event: "toggle-original",
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const btns = computed(() => {
|
||||
const out = [
|
||||
{
|
||||
icon: $globals.icons.dotsVertical,
|
||||
text: i18n.t("general.menu"),
|
||||
event: "open",
|
||||
children: contextMenuOptions.value,
|
||||
},
|
||||
];
|
||||
|
||||
// If delete event is being listened for, show delete button
|
||||
// $attrs is not available in <script setup>, so always show if parent listens
|
||||
out.unshift({
|
||||
icon: $globals.icons.delete,
|
||||
text: i18n.t("general.delete"),
|
||||
event: "delete",
|
||||
children: undefined,
|
||||
});
|
||||
|
||||
return out;
|
||||
});
|
||||
|
||||
// Foods
|
||||
const foodStore = useFoodStore();
|
||||
const foodData = useFoodData();
|
||||
const foodSearch = ref("");
|
||||
const foodAutocomplete = ref<HTMLInputElement>();
|
||||
|
||||
async function createAssignFood() {
|
||||
foodData.data.name = foodSearch.value;
|
||||
model.value.food = await foodStore.actions.createOne(foodData.data) || undefined;
|
||||
foodData.reset();
|
||||
foodAutocomplete.value?.blur();
|
||||
}
|
||||
|
||||
// Units
|
||||
const unitStore = useUnitStore();
|
||||
const unitsData = useUnitData();
|
||||
const unitSearch = ref("");
|
||||
const unitAutocomplete = ref<HTMLInputElement>();
|
||||
|
||||
async function createAssignUnit() {
|
||||
unitsData.data.name = unitSearch.value;
|
||||
model.value.unit = await unitStore.actions.createOne(unitsData.data) || undefined;
|
||||
unitsData.reset();
|
||||
unitAutocomplete.value?.blur();
|
||||
}
|
||||
|
||||
function toggleTitle() {
|
||||
if (state.showTitle) {
|
||||
model.value.title = "";
|
||||
}
|
||||
state.showTitle = !state.showTitle;
|
||||
}
|
||||
|
||||
function toggleOriginalText() {
|
||||
state.showOriginalText = !state.showOriginalText;
|
||||
}
|
||||
|
||||
function handleUnitEnter() {
|
||||
if (
|
||||
model.value.unit === undefined
|
||||
|| model.value.unit === null
|
||||
|| !model.value.unit.name.includes(unitSearch.value)
|
||||
) {
|
||||
createAssignUnit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFoodEnter() {
|
||||
if (
|
||||
model.value.food === undefined
|
||||
|| model.value.food === null
|
||||
|| !model.value.food.name.includes(foodSearch.value)
|
||||
) {
|
||||
createAssignFood();
|
||||
}
|
||||
}
|
||||
|
||||
function quantityFilter(e: KeyboardEvent) {
|
||||
if (e.key === "-" || e.key === "+" || e.key === "e") {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
const { showTitle, showOriginalText } = toRefs(state);
|
||||
|
||||
const foods = foodStore.store;
|
||||
const units = unitStore.store;
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="safeMarkup"></div>
|
||||
<div v-html="safeMarkup" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
markup: {
|
||||
type: String,
|
||||
|
@ -17,7 +17,7 @@ export default defineComponent({
|
|||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||
return {
|
||||
safeMarkup,
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,20 +1,38 @@
|
|||
<template>
|
||||
<div class="ma-0 pa-0 text-subtitle-1 dense-markdown ingredient-item">
|
||||
<SafeMarkdown v-if="parsedIng.quantity" class="d-inline" :source="parsedIng.quantity" />
|
||||
<template v-if="parsedIng.unit">{{ parsedIng.unit }} </template>
|
||||
<SafeMarkdown v-if="parsedIng.note && !parsedIng.name" class="text-bold d-inline" :source="parsedIng.note" />
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.quantity"
|
||||
class="d-inline"
|
||||
:source="parsedIng.quantity"
|
||||
/>
|
||||
<template v-if="parsedIng.unit">
|
||||
{{ parsedIng.unit }}
|
||||
</template>
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.note && !parsedIng.name"
|
||||
class="text-bold d-inline"
|
||||
:source="parsedIng.note"
|
||||
/>
|
||||
<template v-else>
|
||||
<SafeMarkdown v-if="parsedIng.name" class="text-bold d-inline" :source="parsedIng.name" />
|
||||
<SafeMarkdown v-if="parsedIng.note" class="note" :source="parsedIng.note" />
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.name"
|
||||
class="text-bold d-inline"
|
||||
:source="parsedIng.name"
|
||||
/>
|
||||
<SafeMarkdown
|
||||
v-if="parsedIng.note"
|
||||
class="note"
|
||||
:source="parsedIng.note"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
ingredient: {
|
||||
type: Object as () => RecipeIngredient,
|
||||
|
@ -40,12 +58,20 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.ingredient-item {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25em;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
|
||||
.d-inline {
|
||||
& > p {
|
||||
display: inline;
|
||||
&:has(>sub)>sup {
|
||||
&:has(> sub) > sup {
|
||||
letter-spacing: -0.05rem;
|
||||
}
|
||||
}
|
||||
|
@ -55,7 +81,7 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
sup {
|
||||
&+span{
|
||||
& + span {
|
||||
letter-spacing: -0.05rem;
|
||||
}
|
||||
&:before {
|
||||
|
@ -66,12 +92,19 @@ export default defineComponent({
|
|||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.note {
|
||||
line-height: 1.25em;
|
||||
flex-basis: 100%;
|
||||
width: 100%;
|
||||
display: block;
|
||||
line-height: 1.3em;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.7;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,20 +1,51 @@
|
|||
<template>
|
||||
<div v-if="value && value.length > 0">
|
||||
<div v-if="!isCookMode" class="d-flex justify-start" >
|
||||
<h2 class="mb-2 mt-1">{{ $t("recipe.ingredients") }}</h2>
|
||||
<AppButtonCopy btn-class="ml-auto" :copy-text="ingredientCopyText" />
|
||||
<div
|
||||
v-if="!isCookMode"
|
||||
class="d-flex justify-start"
|
||||
>
|
||||
<h2 class="mt-1 text-h5 font-weight-medium opacity-80">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</h2>
|
||||
<AppButtonCopy
|
||||
btn-class="ml-auto"
|
||||
:copy-text="ingredientCopyText"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div v-for="(ingredient, index) in value" :key="'ingredient' + index">
|
||||
<div
|
||||
v-for="(ingredient, index) in value"
|
||||
:key="'ingredient' + index"
|
||||
>
|
||||
<template v-if="!isCookMode">
|
||||
<h3 v-if="showTitleEditor[index]" class="mt-2">{{ ingredient.title }}</h3>
|
||||
<v-divider v-if="showTitleEditor[index]"></v-divider>
|
||||
<h3
|
||||
v-if="showTitleEditor[index]"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ ingredient.title }}
|
||||
</h3>
|
||||
<v-divider v-if="showTitleEditor[index]" />
|
||||
</template>
|
||||
<v-list-item dense @click.stop="toggleChecked(index)">
|
||||
<v-checkbox hide-details :value="checked[index]" class="pt-0 my-auto py-auto" color="secondary" />
|
||||
<v-list-item-content :key="ingredient.quantity">
|
||||
<RecipeIngredientListItem :ingredient="ingredient" :disable-amount="disableAmount" :scale="scale" />
|
||||
</v-list-item-content>
|
||||
<v-list-item
|
||||
density="compact"
|
||||
@click.stop="toggleChecked(index)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-checkbox
|
||||
v-model="checked[index]"
|
||||
hide-details
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
density="comfortable"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredient"
|
||||
:disable-amount="disableAmount"
|
||||
:scale="scale"
|
||||
/>
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -22,12 +53,11 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeIngredientListItem },
|
||||
props: {
|
||||
value: {
|
||||
|
@ -45,7 +75,7 @@ export default defineComponent({
|
|||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
function validateTitle(title?: string) {
|
||||
|
@ -54,7 +84,7 @@ export default defineComponent({
|
|||
|
||||
const state = reactive({
|
||||
checked: props.value.map(() => false),
|
||||
showTitleEditor: computed(() => props.value.map((x) => validateTitle(x.title))),
|
||||
showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
|
||||
});
|
||||
|
||||
const ingredientCopyText = computed(() => {
|
||||
|
|
|
@ -4,46 +4,45 @@
|
|||
<BaseDialog
|
||||
v-model="madeThisDialog"
|
||||
:icon="$globals.icons.chefHat"
|
||||
:title="$tc('recipe.made-this')"
|
||||
:submit-text="$tc('recipe.add-to-timeline')"
|
||||
:title="$t('recipe.made-this')"
|
||||
:submit-text="$t('recipe.add-to-timeline')"
|
||||
can-submit
|
||||
@submit="createTimelineEvent"
|
||||
>
|
||||
>
|
||||
<v-card-text>
|
||||
<v-form ref="domMadeThisForm">
|
||||
<v-textarea
|
||||
v-model="newTimelineEvent.eventMessage"
|
||||
autofocus
|
||||
:label="$tc('recipe.comment')"
|
||||
:hint="$tc('recipe.how-did-it-turn-out')"
|
||||
:label="$t('recipe.comment')"
|
||||
:hint="$t('recipe.how-did-it-turn-out')"
|
||||
persistent-hint
|
||||
rows="4"
|
||||
></v-textarea>
|
||||
/>
|
||||
<v-container>
|
||||
<v-row>
|
||||
<v-col cols="auto">
|
||||
<v-col cols="6">
|
||||
<v-menu
|
||||
v-model="datePickerMenu"
|
||||
:close-on-content-click="false"
|
||||
transition="scale-transition"
|
||||
offset-y
|
||||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-text-field
|
||||
v-model="newTimelineEventTimestamp"
|
||||
v-model="newTimelineEventTimestampString"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
readonly
|
||||
v-on="on"
|
||||
></v-text-field>
|
||||
/>
|
||||
</template>
|
||||
<v-date-picker
|
||||
v-model="newTimelineEventTimestamp"
|
||||
no-title
|
||||
hide-header
|
||||
:first-day-of-week="firstDayOfWeek"
|
||||
:local="$i18n.locale"
|
||||
@input="datePickerMenu = false"
|
||||
@update:model-value="datePickerMenu = false"
|
||||
/>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
|
@ -55,18 +54,16 @@
|
|||
url="none"
|
||||
file-name="image"
|
||||
accept="image/*"
|
||||
:text="$i18n.tc('recipe.upload-image')"
|
||||
:text="$t('recipe.upload-image')"
|
||||
:text-btn="false"
|
||||
:post="false"
|
||||
@uploaded="uploadImage"
|
||||
/>
|
||||
<v-btn
|
||||
v-if="!!newTimelineEventImage"
|
||||
color="error"
|
||||
@click="clearImage"
|
||||
>
|
||||
<v-icon left>{{ $globals.icons.close }}</v-icon>
|
||||
{{ $i18n.tc('recipe.remove-image') }}
|
||||
<v-btn v-if="!!newTimelineEventImage" color="error" @click="clearImage">
|
||||
<v-icon start>
|
||||
{{ $globals.icons.close }}
|
||||
</v-icon>
|
||||
{{ $t("recipe.remove-image") }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
@ -87,24 +84,31 @@
|
|||
</div>
|
||||
<div>
|
||||
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
|
||||
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger;">
|
||||
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
|
||||
<v-tooltip bottom>
|
||||
<template #activator="{ on, attrs }">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
rounded
|
||||
outlined
|
||||
x-large
|
||||
color="primary"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
variant="outlined"
|
||||
size="x-large"
|
||||
v-bind="props"
|
||||
style="border-color: rgb(var(--v-theme-primary));"
|
||||
@click="madeThisDialog = true"
|
||||
>
|
||||
<v-icon left large>{{ $globals.icons.calendar }}</v-icon>
|
||||
<span class="text--secondary" style="letter-spacing: normal;"><b>{{ $tc("general.last-made") }}</b><br>{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $tc("general.never") }}</span>
|
||||
<v-icon right large>{{ $globals.icons.createAlt }}</v-icon>
|
||||
<v-icon start size="large" color="primary">
|
||||
{{ $globals.icons.calendar }}
|
||||
</v-icon>
|
||||
<span class="text-body-1 opacity-80">
|
||||
<b>{{ $t("general.last-made") }}</b>
|
||||
<br>
|
||||
{{ lastMade ? new Date(lastMade).toLocaleDateString($i18n.locale) : $t("general.never") }}
|
||||
</span>
|
||||
<v-icon end size="large" color="primary">
|
||||
{{ $globals.icons.createAlt }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<span>{{ $tc("recipe.made-this") }}</span>
|
||||
<span>{{ $t("recipe.made-this") }}</span>
|
||||
</v-tooltip>
|
||||
</v-row>
|
||||
</div>
|
||||
|
@ -113,25 +117,26 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useHouseholdSelf } from "~/composables/use-households";
|
||||
import { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
||||
import type { Recipe, RecipeTimelineEventIn } from "~/lib/api/types/recipe";
|
||||
import type { VForm } from "~/types/auto-forms";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["eventCreated"],
|
||||
setup(props, context) {
|
||||
const madeThisDialog = ref(false);
|
||||
const userApi = useUserApi();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { $auth, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
|
@ -143,14 +148,18 @@ export default defineComponent({
|
|||
const newTimelineEventImage = ref<Blob | File>();
|
||||
const newTimelineEventImageName = ref<string>("");
|
||||
const newTimelineEventImagePreviewUrl = ref<string>();
|
||||
const newTimelineEventTimestamp = ref<string>();
|
||||
const newTimelineEventTimestamp = ref<Date>(new Date());
|
||||
const newTimelineEventTimestampString = computed(() => {
|
||||
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.householdSlug) {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||
lastMade.value = data?.lastMade;
|
||||
}
|
||||
|
@ -158,15 +167,12 @@ export default defineComponent({
|
|||
lastMadeReady.value = true;
|
||||
});
|
||||
|
||||
|
||||
whenever(
|
||||
() => madeThisDialog.value,
|
||||
() => {
|
||||
// Set timestamp to now
|
||||
newTimelineEventTimestamp.value = (
|
||||
new Date(Date.now() - (new Date()).getTimezoneOffset() * 60000)
|
||||
).toISOString().substring(0, 10);
|
||||
}
|
||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||
},
|
||||
);
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
|
@ -190,19 +196,19 @@ export default defineComponent({
|
|||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
const state = reactive({datePickerMenu: false});
|
||||
const state = reactive({ datePickerMenu: false });
|
||||
async function createTimelineEvent() {
|
||||
if (!(newTimelineEventTimestamp.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
newTimelineEvent.value.recipeId = props.recipe.id
|
||||
// @ts-expect-error - TS doesn't like the $auth global user attribute
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.fullName })
|
||||
newTimelineEvent.value.recipeId = props.recipe.id;
|
||||
// Note: $auth.user is now a ref
|
||||
newTimelineEvent.value.subject = i18n.t("recipe.user-made-this", { user: $auth.user.value?.fullName });
|
||||
|
||||
// the user only selects the date, so we set the time to end of day local time
|
||||
// we choose the end of day so it always comes after "new recipe" events
|
||||
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestamp.value + "T23:59:59").toISOString();
|
||||
newTimelineEvent.value.timestamp = new Date(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
||||
|
||||
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
||||
const newEvent = eventResponse.data;
|
||||
|
@ -210,7 +216,7 @@ export default defineComponent({
|
|||
// we also update the recipe's last made value
|
||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||
lastMade.value = newTimelineEvent.value.timestamp;
|
||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||
}
|
||||
|
||||
// update the image, if provided
|
||||
|
@ -221,7 +227,6 @@ export default defineComponent({
|
|||
newTimelineEventImageName.value,
|
||||
);
|
||||
if (imageResponse.data) {
|
||||
// @ts-ignore the image response data will always match a value of TimelineEventImage
|
||||
newEvent.image = imageResponse.data.image;
|
||||
}
|
||||
}
|
||||
|
@ -245,6 +250,7 @@ export default defineComponent({
|
|||
newTimelineEventImage,
|
||||
newTimelineEventImagePreviewUrl,
|
||||
newTimelineEventTimestamp,
|
||||
newTimelineEventTimestampString,
|
||||
lastMade,
|
||||
lastMadeReady,
|
||||
createTimelineEvent,
|
||||
|
|
|
@ -7,34 +7,57 @@
|
|||
:class="attrs.class.sheet"
|
||||
:style="tile ? 'max-width: 100%; width: fit-content;' : 'width: 100%;'"
|
||||
>
|
||||
<v-list-item :to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug" :class="attrs.class.listItem">
|
||||
<v-list-item-avatar :class="attrs.class.avatar">
|
||||
<v-icon :class="attrs.class.icon" dark :small="small"> {{ $globals.icons.primary }} </v-icon>
|
||||
</v-list-item-avatar>
|
||||
<v-list-item-content :class="attrs.class.text">
|
||||
<v-list-item-title :class="listItem && listItemDescriptions[index] ? '' : 'pr-4'" :style="attrs.style.text.title">
|
||||
<v-list-item
|
||||
:to="disabled ? '' : '/g/' + groupSlug + '/r/' + recipe.slug"
|
||||
:class="attrs.class.listItem"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-avatar color="primary" :class="attrs.class.avatar">
|
||||
<v-icon
|
||||
:class="attrs.class.icon"
|
||||
dark
|
||||
:size="small ? 'small' : 'default'"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
</template>
|
||||
<div :class="attrs.class.text">
|
||||
<v-list-item-title
|
||||
:class="listItem && listItemDescriptions[index] ? '' : 'pr-4'"
|
||||
:style="attrs.style.text.title"
|
||||
>
|
||||
{{ recipe.name }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="showDescription">{{ recipe.description }}</v-list-item-subtitle>
|
||||
<v-list-item-subtitle v-if="listItem && listItemDescriptions[index]" :style="attrs.style.text.subTitle">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="listItemDescriptions[index]"></div>
|
||||
<v-list-item-subtitle v-if="showDescription">
|
||||
{{ recipe.description }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
<slot :name="'actions-' + recipe.id" :v-bind="{ item: recipe }"> </slot>
|
||||
<v-list-item-subtitle
|
||||
v-if="listItem && listItemDescriptions[index]"
|
||||
:style="attrs.style.text.subTitle"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="listItemDescriptions[index]" />
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
<template #append>
|
||||
<slot
|
||||
:name="'actions-' + recipe.id"
|
||||
:v-bind="{ item: recipe }"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-sheet>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useFraction } from "~/composables/recipes/use-fraction";
|
||||
import { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipes: {
|
||||
type: Array as () => RecipeSummary[],
|
||||
|
@ -59,44 +82,46 @@ export default defineComponent({
|
|||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
const attrs = computed(() => {
|
||||
return props.small ? {
|
||||
class: {
|
||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-0",
|
||||
avatar: "ma-0",
|
||||
icon: "ma-0 pa-0 primary",
|
||||
text: "pa-0",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "font-size: small;",
|
||||
subTitle: "font-size: x-small;",
|
||||
},
|
||||
},
|
||||
} : {
|
||||
class: {
|
||||
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-4",
|
||||
avatar: "",
|
||||
icon: "pa-1 primary",
|
||||
text: "",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "",
|
||||
subTitle: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
return props.small
|
||||
? {
|
||||
class: {
|
||||
sheet: props.tile ? "mb-1 me-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-0",
|
||||
avatar: "ma-0",
|
||||
icon: "ma-0 pa-0 primary",
|
||||
text: "pa-0",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "font-size: small;",
|
||||
subTitle: "font-size: x-small;",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
class: {
|
||||
sheet: props.tile ? "mx-1 justify-center align-center" : "mb-1 justify-center align-center",
|
||||
listItem: "px-4",
|
||||
avatar: "",
|
||||
icon: "pa-1 primary",
|
||||
text: "",
|
||||
},
|
||||
style: {
|
||||
text: {
|
||||
title: "",
|
||||
subTitle: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
|
@ -108,11 +133,11 @@ export default defineComponent({
|
|||
|
||||
const listItemDescriptions = computed<string[]>(() => {
|
||||
if (
|
||||
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
||||
|| !props.listItem?.recipeReferences
|
||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||
) {
|
||||
return props.recipes.map((_) => "")
|
||||
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref
|
||||
|| !props.listItem?.recipeReferences
|
||||
|| props.listItem.recipeReferences.length !== props.recipes.length
|
||||
) {
|
||||
return props.recipes.map(_ => "");
|
||||
}
|
||||
|
||||
const listItemDescriptions: string[] = [];
|
||||
|
@ -120,36 +145,37 @@ export default defineComponent({
|
|||
const itemRef = props.listItem?.recipeReferences[i];
|
||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||
|
||||
let listItemDescription = ""
|
||||
let listItemDescription = "";
|
||||
if (props.listItem.unit?.fraction) {
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity*100)/100).toString();
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||
}
|
||||
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation : props.listItem.unit.name;
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation
|
||||
: props.listItem.unit.name;
|
||||
|
||||
listItemDescription += ` ${unitDisplay}`
|
||||
}
|
||||
listItemDescription += ` ${unitDisplay}`;
|
||||
}
|
||||
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`
|
||||
}
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||
}
|
||||
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
}
|
||||
|
||||
return listItemDescriptions;
|
||||
|
|
|
@ -1,16 +1,40 @@
|
|||
<template>
|
||||
<div v-if="value.length > 0 || edit" class="mt-8">
|
||||
<h2 class="my-4">{{ $t("recipe.note") }}</h2>
|
||||
<div v-for="(note, index) in value" :id="'note' + index" :key="'note' + index" class="mt-1">
|
||||
<div
|
||||
v-if="model.length > 0 || edit"
|
||||
class="mt-8"
|
||||
>
|
||||
<h2 class="my-4 text-h5 font-weight-medium opacity-80">
|
||||
{{ $t("recipe.note") }}
|
||||
</h2>
|
||||
<div
|
||||
v-for="(note, index) in model"
|
||||
:id="'note' + index"
|
||||
:key="'note' + index"
|
||||
class="mt-1"
|
||||
>
|
||||
<v-card v-if="edit">
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field v-model="value[index]['title']" :label="$t('recipe.title')" />
|
||||
<v-btn icon class="mr-2" elevation="0" @click="removeByIndex(value, index)">
|
||||
<v-text-field
|
||||
v-model="model[index]['title']"
|
||||
variant="underlined"
|
||||
:label="$t('recipe.title')"
|
||||
/>
|
||||
<v-btn
|
||||
icon
|
||||
class="mr-2"
|
||||
elevation="0"
|
||||
@click="removeByIndex(index)"
|
||||
>
|
||||
<v-icon>{{ $globals.icons.delete }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-textarea v-model="value[index]['text']" auto-grow :placeholder="$t('recipe.note')" />
|
||||
<v-textarea
|
||||
v-model="model[index]['text']"
|
||||
variant="underlined"
|
||||
auto-grow
|
||||
:placeholder="$t('recipe.note')"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<div v-else>
|
||||
|
@ -23,44 +47,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="edit" class="d-flex justify-end">
|
||||
<BaseButton class="ml-auto my-2" @click="addNote"> {{ $t("general.add") }}</BaseButton>
|
||||
<div
|
||||
v-if="edit"
|
||||
class="d-flex justify-end"
|
||||
>
|
||||
<BaseButton
|
||||
class="ml-auto my-2"
|
||||
@click="addNote"
|
||||
>
|
||||
{{ $t("general.add") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { RecipeNote } from "~/lib/api/types/recipe";
|
||||
<script setup lang="ts">
|
||||
import type { RecipeNote } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => RecipeNote[],
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
const model = defineModel<RecipeNote[]>({ default: () => [] });
|
||||
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
function addNote() {
|
||||
props.value.push({ title: "", text: "" });
|
||||
}
|
||||
|
||||
function removeByIndex(list: unknown[], index: number) {
|
||||
list.splice(index, 1);
|
||||
}
|
||||
|
||||
return {
|
||||
addNote,
|
||||
removeByIndex,
|
||||
};
|
||||
defineProps({
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
function addNote() {
|
||||
model.value = [...model.value, { title: "", text: "" }];
|
||||
}
|
||||
|
||||
function removeByIndex(index: number) {
|
||||
const newNotes = [...model.value];
|
||||
newNotes.splice(index, 1);
|
||||
model.value = newNotes;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,23 +4,42 @@
|
|||
<v-card-title class="pt-2 pb-0">
|
||||
{{ $t("recipe.nutrition") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2 my-1"></v-divider>
|
||||
<v-divider class="mx-2 my-1" />
|
||||
<v-card-text v-if="edit">
|
||||
<div v-for="(item, key, index) in value" :key="index">
|
||||
<div
|
||||
v-for="(item, key, index) in modelValue"
|
||||
: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>
|
||||
density="compact"
|
||||
:model-value="modelValue[key]"
|
||||
:label="labels[key].label"
|
||||
:suffix="labels[key].suffix"
|
||||
type="number"
|
||||
autocomplete="off"
|
||||
@update:model-value="updateValue(key, $event)"
|
||||
/>
|
||||
</div>
|
||||
</v-card-text>
|
||||
<v-list v-if="showViewer" dense class="mt-0 pt-0">
|
||||
<v-list-item v-for="(item, key, index) in renderedList" :key="index" style="min-height: 25px" dense>
|
||||
<v-list-item-content>
|
||||
<v-list
|
||||
v-if="showViewer"
|
||||
density="compact"
|
||||
class="mt-0 pt-0"
|
||||
>
|
||||
<v-list-item
|
||||
v-for="(item, key, index) in renderedList"
|
||||
:key="index"
|
||||
style="min-height: 25px"
|
||||
>
|
||||
<div>
|
||||
<v-list-item-title class="pl-4 caption flex row">
|
||||
<div>{{ item.label }}</div>
|
||||
<div class="ml-auto mr-1">{{ item.value }}</div>
|
||||
<div class="ml-auto mr-1">
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div>{{ item.suffix }}</div>
|
||||
</v-list-item-title>
|
||||
</v-list-item-content>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
|
@ -28,13 +47,13 @@ dense :value="value[key]" :label="labels[key].label" :suffix="labels[key].suffix
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useNutritionLabels } from "~/composables/recipes";
|
||||
import { Nutrition } from "~/lib/api/types/recipe";
|
||||
import { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||
export default defineComponent({
|
||||
import type { Nutrition } from "~/lib/api/types/recipe";
|
||||
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Object as () => Nutrition,
|
||||
required: true,
|
||||
},
|
||||
|
@ -43,12 +62,13 @@ export default defineComponent({
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { labels } = useNutritionLabels();
|
||||
const valueNotNull = computed(() => {
|
||||
let key: keyof Nutrition;
|
||||
for (key in props.value) {
|
||||
if (props.value[key] !== null) {
|
||||
for (key in props.modelValue) {
|
||||
if (props.modelValue[key] !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -58,16 +78,16 @@ export default defineComponent({
|
|||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||
|
||||
function updateValue(key: number | string, event: Event) {
|
||||
context.emit("input", { ...props.value, [key]: event });
|
||||
context.emit("update:modelValue", { ...props.modelValue, [key]: event });
|
||||
}
|
||||
|
||||
// Build a new list that only contains nutritional information that has a value
|
||||
const renderedList = computed(() => {
|
||||
return Object.entries(labels).reduce((item: NutritionLabelType, [key, label]) => {
|
||||
if (props.value[key]?.trim()) {
|
||||
if (props.modelValue[key]?.trim()) {
|
||||
item[key] = {
|
||||
...label,
|
||||
value: props.value[key],
|
||||
value: props.modelValue[key],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
|
|
|
@ -1,36 +1,58 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-dialog v-model="dialog" width="500">
|
||||
<v-dialog
|
||||
v-model="dialog"
|
||||
width="500"
|
||||
>
|
||||
<v-card>
|
||||
<v-app-bar dense dark color="primary mb-2">
|
||||
<v-icon large left class="mt-1">
|
||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam :
|
||||
itemType === Organizer.Category ? $globals.icons.categories :
|
||||
$globals.icons.tags }}
|
||||
<v-app-bar
|
||||
density="compact"
|
||||
dark
|
||||
color="primary mb-2 position-relative left-0 top-0 w-100 pl-3"
|
||||
>
|
||||
<v-icon
|
||||
size="large"
|
||||
start
|
||||
class="mt-1"
|
||||
>
|
||||
{{ itemType === Organizer.Tool ? $globals.icons.potSteam
|
||||
: itemType === Organizer.Category ? $globals.icons.categories
|
||||
: $globals.icons.tags }}
|
||||
</v-icon>
|
||||
|
||||
<v-toolbar-title class="headline">
|
||||
{{ properties.title }}
|
||||
</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
</v-app-bar>
|
||||
<v-card-title> </v-card-title>
|
||||
<v-card-title />
|
||||
<v-form @submit.prevent="select">
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-model="name"
|
||||
dense
|
||||
density="compact"
|
||||
:label="properties.label"
|
||||
:rules="[rules.required]"
|
||||
autofocus
|
||||
></v-text-field>
|
||||
<v-checkbox v-if="itemType === Organizer.Tool" v-model="onHand" :label="$t('tool.on-hand')"></v-checkbox>
|
||||
/>
|
||||
<v-checkbox
|
||||
v-if="itemType === Organizer.Tool"
|
||||
v-model="onHand"
|
||||
:label="$t('tool.on-hand')"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false" />
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton type="submit" create :disabled="!name" />
|
||||
<BaseButton
|
||||
cancel
|
||||
@click="dialog = false"
|
||||
/>
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
type="submit"
|
||||
create
|
||||
:disabled="!name"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card>
|
||||
|
@ -39,16 +61,15 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, toRefs, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
||||
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
||||
|
||||
const CREATED_ITEM_EVENT = "created-item";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -65,8 +86,9 @@ export default defineComponent({
|
|||
default: "category",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
name: "",
|
||||
|
@ -75,18 +97,18 @@ export default defineComponent({
|
|||
|
||||
const dialog = computed({
|
||||
get() {
|
||||
return props.value;
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
context.emit("input", value);
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
(val: boolean) => {
|
||||
if (!val) state.name = "";
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
@ -135,7 +157,7 @@ export default defineComponent({
|
|||
await store.actions.createOne({ ...state });
|
||||
}
|
||||
|
||||
const newItem = store.store.value.find((item) => item.name === state.name);
|
||||
const newItem = store.store.value.find(item => item.name === state.name);
|
||||
|
||||
context.emit(CREATED_ITEM_EVENT, newItem);
|
||||
dialog.value = false;
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
<template>
|
||||
<div v-if="items">
|
||||
<RecipeOrganizerDialog v-model="dialogs.organizer" :item-type="itemType" />
|
||||
<RecipeOrganizerDialog
|
||||
v-model="dialogs.organizer"
|
||||
:item-type="itemType"
|
||||
/>
|
||||
|
||||
<BaseDialog
|
||||
v-if="deleteTarget"
|
||||
|
@ -8,18 +11,34 @@
|
|||
:title="$t('general.delete-with-name', { name: $t(translationKey) })"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="deleteOne()"
|
||||
>
|
||||
<v-card-text>
|
||||
<p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p>
|
||||
<p class="mt-4 mb-0 ml-4">{{ deleteTarget.name }}</p>
|
||||
<p>{{ $t("general.confirm-delete-generic-with-name", { name: $t(translationKey) }) }}</p>
|
||||
<p class="mt-4 mb-0 ml-4">
|
||||
{{ deleteTarget.name }}
|
||||
</p>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog v-if="updateTarget" v-model="dialogs.update" :title="$t('general.update')" @confirm="updateOne()">
|
||||
<BaseDialog
|
||||
v-if="updateTarget"
|
||||
v-model="dialogs.update"
|
||||
:title="$t('general.update')"
|
||||
can-confirm
|
||||
@confirm="updateOne()"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-text-field v-model="updateTarget.name" :label="$t('general.name')"> </v-text-field>
|
||||
<v-checkbox v-if="itemType === Organizer.Tool" v-model="updateTarget.onHand" :label="$t('tool.on-hand')"></v-checkbox>
|
||||
<v-text-field
|
||||
v-model="updateTarget.name"
|
||||
:label="$t('general.name')"
|
||||
/>
|
||||
<v-checkbox
|
||||
v-if="itemType === Organizer.Tool"
|
||||
v-model="updateTarget.onHand"
|
||||
:label="$t('tool.on-hand')"
|
||||
/>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
|
@ -27,32 +46,61 @@
|
|||
<v-col>
|
||||
<v-text-field
|
||||
v-model="searchString"
|
||||
outlined
|
||||
variant="outlined"
|
||||
autofocus
|
||||
color="primary accent-3"
|
||||
:placeholder="$t('search.search-placeholder')"
|
||||
:prepend-inner-icon="$globals.icons.search"
|
||||
clearable
|
||||
>
|
||||
</v-text-field>
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-app-bar color="transparent" flat class="mt-n1 rounded align-center">
|
||||
<v-icon large left>
|
||||
<v-app-bar
|
||||
color="transparent"
|
||||
flat
|
||||
class="mt-n1 rounded align-center px-4 position-relative w-100 left-0 top-0"
|
||||
>
|
||||
<v-icon
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ icon }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline">
|
||||
<slot name="title"> </slot>
|
||||
<slot name="title" />
|
||||
</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton create @click="dialogs.organizer = true" />
|
||||
<v-spacer />
|
||||
<BaseButton
|
||||
create
|
||||
@click="dialogs.organizer = true"
|
||||
/>
|
||||
</v-app-bar>
|
||||
<section v-for="(itms, key, idx) in itemsSorted" :key="'header' + idx" :class="idx === 1 ? null : 'my-4'">
|
||||
<BaseCardSectionTitle v-if="isTitle(key)" :title="key" />
|
||||
<section
|
||||
v-for="(itms, key, idx) in itemsSorted"
|
||||
:key="'header' + idx"
|
||||
:class="idx === 1 ? null : 'my-4'"
|
||||
>
|
||||
<BaseCardSectionTitle
|
||||
v-if="isTitle(key)"
|
||||
:title="key"
|
||||
/>
|
||||
<v-row>
|
||||
<v-col v-for="(item, index) in itms" :key="'cat' + index" cols="12" :sm="12" :md="6" :lg="4" :xl="3">
|
||||
<v-card v-if="item" class="left-border" hover :to="`/g/${groupSlug}?${itemType}=${item.id}`">
|
||||
<v-col
|
||||
v-for="(item, index) in itms"
|
||||
:key="'cat' + index"
|
||||
cols="12"
|
||||
:sm="12"
|
||||
:md="6"
|
||||
:lg="4"
|
||||
:xl="3"
|
||||
>
|
||||
<v-card
|
||||
v-if="item"
|
||||
class="left-border"
|
||||
hover
|
||||
:to="`/g/${groupSlug}?${itemType}=${item.id}`"
|
||||
>
|
||||
<v-card-actions>
|
||||
<v-icon>
|
||||
{{ icon }}
|
||||
|
@ -60,7 +108,7 @@
|
|||
<v-card-title class="py-1">
|
||||
{{ item.name }}
|
||||
</v-card-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-spacer />
|
||||
<ContextMenu
|
||||
:items="[presets.delete, presets.edit]"
|
||||
@delete="confirmDelete(item)"
|
||||
|
@ -76,10 +124,10 @@
|
|||
|
||||
<script lang="ts">
|
||||
import Fuse from "fuse.js";
|
||||
import { defineComponent, computed, ref, reactive, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
|
||||
import { useContextPresets } from "~/composables/use-context-presents";
|
||||
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
|
||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import { useRouteQuery } from "~/composables/use-router";
|
||||
import { deepCopy } from "~/composables/use-utils";
|
||||
|
||||
|
@ -90,7 +138,7 @@ interface GenericItem {
|
|||
onHand: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
|
@ -108,6 +156,7 @@ export default defineComponent({
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update", "delete"],
|
||||
setup(props, { emit }) {
|
||||
const state = reactive({
|
||||
// Search Options
|
||||
|
@ -124,9 +173,9 @@ export default defineComponent({
|
|||
},
|
||||
});
|
||||
|
||||
const { $auth } = useContext();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
@ -141,11 +190,11 @@ export default defineComponent({
|
|||
|
||||
const translationKey = computed<string>(() => {
|
||||
const typeMap = {
|
||||
"categories": "category.category",
|
||||
"tags": "tag.tag",
|
||||
"tools": "tool.tool",
|
||||
"foods": "shopping-list.food",
|
||||
"households": "household.household",
|
||||
categories: "category.category",
|
||||
tags: "tag.tag",
|
||||
tools: "tool.tool",
|
||||
foods: "shopping-list.food",
|
||||
households: "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
|
@ -193,7 +242,7 @@ export default defineComponent({
|
|||
return props.items;
|
||||
}
|
||||
const result = fuse.value.search(searchString.value.trim() as string);
|
||||
return result.map((x) => x.item);
|
||||
return result.map(x => x.item);
|
||||
});
|
||||
|
||||
// =================================================================
|
||||
|
@ -206,7 +255,7 @@ export default defineComponent({
|
|||
return byLetter;
|
||||
}
|
||||
|
||||
fuzzyItems.value
|
||||
[...fuzzyItems.value]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
|
@ -240,7 +289,5 @@ export default defineComponent({
|
|||
translationKey,
|
||||
};
|
||||
},
|
||||
// Needed for useMeta
|
||||
head: {},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,62 +1,61 @@
|
|||
<template>
|
||||
<v-autocomplete
|
||||
v-model="selected"
|
||||
v-bind="inputAttrs"
|
||||
v-model:search="searchInput"
|
||||
:items="storeItem"
|
||||
:value="value"
|
||||
:label="label"
|
||||
chips
|
||||
deletable-chips
|
||||
item-text="name"
|
||||
closable-chips
|
||||
item-title="name"
|
||||
multiple
|
||||
variant="underlined"
|
||||
:prepend-inner-icon="icon"
|
||||
:append-icon="$globals.icons.create"
|
||||
return-object
|
||||
v-bind="inputAttrs"
|
||||
auto-select-first
|
||||
:search-input.sync="searchInput"
|
||||
class="pa-0"
|
||||
@change="resetSearchInput"
|
||||
@update:model-value="resetSearchInput"
|
||||
@click:append="dialog = true"
|
||||
>
|
||||
<template #selection="data">
|
||||
<template #chip="{ item, index }">
|
||||
<v-chip
|
||||
:key="data.index"
|
||||
:key="index"
|
||||
class="ma-1"
|
||||
:input-value="data.selected"
|
||||
small
|
||||
close
|
||||
label
|
||||
color="accent"
|
||||
dark
|
||||
@click:close="removeByIndex(data.index)"
|
||||
variant="flat"
|
||||
label
|
||||
|
||||
closable
|
||||
@click:close="removeByIndex(index)"
|
||||
>
|
||||
{{ data.item.name || data.item }}
|
||||
{{ item.value }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template v-if="showAdd" #append-outer>
|
||||
<v-btn icon @click="dialog = true">
|
||||
<v-icon>
|
||||
{{ $globals.icons.create }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<RecipeOrganizerDialog v-model="dialog" :item-type="selectorType" @created-item="appendCreated" />
|
||||
|
||||
<template
|
||||
v-if="showAdd"
|
||||
#append
|
||||
>
|
||||
<RecipeOrganizerDialog
|
||||
v-model="dialog"
|
||||
:item-type="selectorType"
|
||||
@created-item="appendCreated"
|
||||
/>
|
||||
</template>
|
||||
</v-autocomplete>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref, useContext, computed, onMounted } from "@nuxtjs/composition-api";
|
||||
import RecipeOrganizerDialog from "./RecipeOrganizerDialog.vue";
|
||||
import { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import { RecipeTool } from "~/lib/api/types/admin";
|
||||
import type { IngredientFood, RecipeCategory, RecipeTag } from "~/lib/api/types/recipe";
|
||||
import type { RecipeTool } from "~/lib/api/types/admin";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { useCategoryStore, useFoodStore, useHouseholdStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { Organizer, RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => (
|
||||
| HouseholdSummary
|
||||
| RecipeTag
|
||||
|
@ -95,12 +94,13 @@ export default defineComponent({
|
|||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
|
||||
setup(props, context) {
|
||||
const selected = computed({
|
||||
get: () => props.value,
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -110,7 +110,8 @@ export default defineComponent({
|
|||
}
|
||||
});
|
||||
|
||||
const { $globals, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const label = computed(() => {
|
||||
if (!props.showLabel) {
|
||||
|
@ -168,11 +169,11 @@ export default defineComponent({
|
|||
const store = computed(() => {
|
||||
const { store } = storeMap[props.selectorType];
|
||||
return store.value;
|
||||
})
|
||||
});
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.returnObject) {
|
||||
return store.value.map((item) => item.name);
|
||||
return store.value.map(item => item.name);
|
||||
}
|
||||
return store.value;
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-container v-show="!isCookMode" key="recipe-page" :class="{ 'pa-0': $vuetify.breakpoint.smAndDown }">
|
||||
<v-card :flat="$vuetify.breakpoint.smAndDown" class="d-print-none">
|
||||
<v-container v-show="!isCookMode" key="recipe-page" class="pt-0" :class="{ 'pa-0': $vuetify.display.smAndDown.value }">
|
||||
<v-card :flat="$vuetify.display.smAndDown.value" class="d-print-none">
|
||||
<RecipePageHeader
|
||||
:recipe="recipe"
|
||||
:recipe-scale="scale"
|
||||
|
@ -9,7 +9,13 @@
|
|||
@save="saveRecipe"
|
||||
@delete="deleteRecipe"
|
||||
/>
|
||||
<LazyRecipeJsonEditor v-if="isEditJSON" v-model="recipe" class="mt-10" :options="EDITOR_OPTIONS" />
|
||||
<RecipeJsonEditor
|
||||
v-if="isEditJSON"
|
||||
v-model="recipe"
|
||||
class="mt-10"
|
||||
mode="text"
|
||||
:main-menu-bar="false"
|
||||
/>
|
||||
<v-card-text v-else>
|
||||
<!--
|
||||
This is where most of the main content is rendered. Some components include state for both Edit and View modes
|
||||
|
@ -21,10 +27,18 @@
|
|||
a significant amount of prop management. When we move to Vue 3 and have access to some of the newer API's the plan to update this
|
||||
data management and mutation system we're using.
|
||||
-->
|
||||
<RecipePageInfoEditor v-if="isEditMode" :recipe="recipe" :landscape="landscape" />
|
||||
<RecipePageEditorToolbar v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageIngredientEditor v-if="isEditForm" :recipe="recipe" />
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
<div>
|
||||
<RecipePageInfoEditor v-if="isEditMode" v-model="recipe" />
|
||||
</div>
|
||||
<div>
|
||||
<RecipePageEditorToolbar v-if="isEditForm" v-model="recipe" />
|
||||
</div>
|
||||
<div>
|
||||
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
|
||||
</div>
|
||||
<div>
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
</div>
|
||||
|
||||
<!--
|
||||
This section contains the 2 column layout for the recipe steps and other content.
|
||||
|
@ -35,9 +49,9 @@
|
|||
-->
|
||||
<v-col v-if="!isCookMode || isEditForm" cols="12" sm="12" md="4" lg="4">
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" />
|
||||
<RecipePageOrganizers v-if="$vuetify.breakpoint.mdAndUp" :recipe="recipe" @item-selected="chipClicked" />
|
||||
<RecipePageOrganizers v-if="$vuetify.display.mdAndUp" v-model="recipe" @item-selected="chipClicked" />
|
||||
</v-col>
|
||||
<v-divider v-if="$vuetify.breakpoint.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
||||
<v-divider v-if="$vuetify.display.mdAndUp && !isCookMode" class="my-divider" :vertical="true" />
|
||||
|
||||
<!--
|
||||
the right column is always rendered, but it's layout width is determined by where the left column is
|
||||
|
@ -46,104 +60,102 @@
|
|||
<v-col cols="12" sm="12" :md="8 + (isCookMode ? 1 : 0) * 4" :lg="8 + (isCookMode ? 1 : 0) * 4">
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
:assets.sync="recipe.assets"
|
||||
v-model:assets="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
<div v-if="isEditForm" class="d-flex">
|
||||
<RecipeDialogBulkAdd class="ml-auto my-2 mr-1" @bulk-data="addStep" />
|
||||
<BaseButton class="my-2" @click="addStep()"> {{ $t("general.add") }}</BaseButton>
|
||||
<BaseButton class="my-2" @click="addStep()">
|
||||
{{ $t("general.add") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div v-if="!$vuetify.breakpoint.mdAndUp">
|
||||
<RecipePageOrganizers :recipe="recipe" />
|
||||
<div v-if="!$vuetify.display.mdAndUp">
|
||||
<RecipePageOrganizers v-model="recipe" />
|
||||
</div>
|
||||
<RecipeNotes v-model="recipe.notes" :edit="isEditForm" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<RecipePageFooter :recipe="recipe" />
|
||||
<RecipePageFooter v-model="recipe" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<WakelockSwitch/>
|
||||
<WakelockSwitch />
|
||||
<RecipePageComments
|
||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||
:recipe="recipe"
|
||||
v-model="recipe"
|
||||
class="px-1 my-4 d-print-none"
|
||||
/>
|
||||
<RecipePrintContainer :recipe="recipe" :scale="scale" />
|
||||
</v-container>
|
||||
<!-- Cook mode displayes two columns with ingredients and instructions side by side, each being scrolled individually, allowing to view both at the same timer -->
|
||||
<v-sheet v-show="isCookMode && !hasLinkedIngredients" key="cookmode" :style="{height: $vuetify.breakpoint.smAndUp ? 'calc(100vh - 48px)' : ''}"> <!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
||||
<v-row style="height: 100%;" no-gutters class="overflow-hidden">
|
||||
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%;">
|
||||
<v-sheet
|
||||
v-show="isCookMode && !hasLinkedIngredients"
|
||||
key="cookmode"
|
||||
:style="{ height: $vuetify.display.smAndUp ? 'calc(100vh - 48px)' : '' }"
|
||||
>
|
||||
<!-- the calc is to account for the toolbar a more dynamic solution could be needed -->
|
||||
<v-row style="height: 100%" no-gutters class="overflow-hidden">
|
||||
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
|
||||
<div class="d-flex align-center">
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale" />
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
</div>
|
||||
<RecipePageIngredientToolsView v-if="!isEditForm" :recipe="recipe" :scale="scale" :is-cook-mode="isCookMode" />
|
||||
<v-divider></v-divider>
|
||||
<RecipePageIngredientToolsView
|
||||
v-if="!isEditForm"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
<v-divider />
|
||||
</v-col>
|
||||
<v-col class="overflow-y-auto py-2" style="height: 100%;" cols="12" sm="7">
|
||||
<v-col class="overflow-y-auto py-2" style="height: 100%" cols="12" sm="7">
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
v-model:assets="recipe.assets"
|
||||
class="overflow-y-hidden px-4"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
</v-sheet>
|
||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||
<div class="mt-2 px-2 px-md-4">
|
||||
<RecipePageScale :recipe="recipe" :scale.sync="scale"/>
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
</div>
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
v-model:assets="recipe.assets"
|
||||
class="overflow-y-hidden mt-n5 px-2 px-md-4"
|
||||
:assets.sync="recipe.assets"
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
/>
|
||||
|
||||
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4 ">
|
||||
<v-divider></v-divider>
|
||||
<div v-if="notLinkedIngredients.length > 0" class="px-2 px-md-4 pb-4">
|
||||
<v-divider />
|
||||
<v-card flat>
|
||||
<v-card-title>{{ $t('recipe.not-linked-ingredients') }}</v-card-title>
|
||||
<RecipeIngredients
|
||||
:value="notLinkedIngredients"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode">
|
||||
|
||||
</RecipeIngredients>
|
||||
<v-card-title>{{ $t("recipe.not-linked-ingredients") }}</v-card-title>
|
||||
<RecipeIngredients
|
||||
:value="notLinkedIngredients"
|
||||
:scale="scale"
|
||||
:disable-amount="recipe.settings.disableAmount"
|
||||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-sheet>
|
||||
<v-btn
|
||||
v-if="isCookMode"
|
||||
fab
|
||||
small
|
||||
icon
|
||||
color="primary"
|
||||
style="position: fixed; right: 12px; top: 60px;"
|
||||
style="position: fixed; right: 12px; top: 60px"
|
||||
@click="toggleCookMode()"
|
||||
>
|
||||
<v-icon>mdi-close</v-icon>
|
||||
>
|
||||
<v-icon>{{ $globals.icons.close }}</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
defineComponent,
|
||||
useContext,
|
||||
useRouter,
|
||||
computed,
|
||||
ref,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
useRoute,
|
||||
} from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { invoke, until } from "@vueuse/core";
|
||||
import RecipeIngredients from "../RecipeIngredients.vue";
|
||||
import RecipePageEditorToolbar from "./RecipePageParts/RecipePageEditorToolbar.vue";
|
||||
|
@ -156,17 +168,14 @@ import RecipePageOrganizers from "./RecipePageParts/RecipePageOrganizers.vue";
|
|||
import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
||||
import RecipePageInfoEditor from "./RecipePageParts/RecipePageInfoEditor.vue";
|
||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipePrintContainer from "~/components/Domain/Recipe/RecipePrintContainer.vue";
|
||||
import {
|
||||
clearPageState,
|
||||
EditorMode,
|
||||
PageMode,
|
||||
usePageState,
|
||||
usePageUser,
|
||||
} from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import { useRouteQuery } from "~/composables/use-router";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { uuid4, deepCopy } from "~/composables/use-utils";
|
||||
|
@ -174,214 +183,172 @@ import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.
|
|||
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
||||
import { useNavigationWarning } from "~/composables/use-navigation-warning";
|
||||
|
||||
const EDITOR_OPTIONS = {
|
||||
mode: "code",
|
||||
search: false,
|
||||
mainMenuBar: false,
|
||||
};
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipePageHeader,
|
||||
RecipePrintContainer,
|
||||
RecipePageComments,
|
||||
RecipePageInfoEditor,
|
||||
RecipePageEditorToolbar,
|
||||
RecipePageIngredientEditor,
|
||||
RecipePageOrganizers,
|
||||
RecipePageScale,
|
||||
RecipePageIngredientToolsView,
|
||||
RecipeDialogBulkAdd,
|
||||
RecipeNotes,
|
||||
RecipePageInstructions,
|
||||
RecipePageFooter,
|
||||
RecipeIngredients,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $auth } = useContext();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
|
||||
const router = useRouter();
|
||||
const api = useUserApi();
|
||||
const { pageMode, editMode, setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode } =
|
||||
usePageState(props.recipe.slug);
|
||||
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||
const notLinkedIngredients = computed(() => {
|
||||
return props.recipe.recipeIngredient.filter((ingredient) => {
|
||||
return !props.recipe.recipeInstructions.some((step) => step.ingredientReferences?.map((ref) => ref.referenceId).includes(ingredient.referenceId));
|
||||
})
|
||||
})
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
/** =============================================================
|
||||
* Recipe Snapshot on Mount
|
||||
* this is used to determine if the recipe has been changed since the last save
|
||||
* and prompts the user to save if they have unsaved changes.
|
||||
*/
|
||||
const originalRecipe = ref<Recipe | null>(null);
|
||||
|
||||
invoke(async () => {
|
||||
await until(props.recipe).not.toBeNull();
|
||||
originalRecipe.value = deepCopy(props.recipe);
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
const isSame = JSON.stringify(props.recipe) === JSON.stringify(originalRecipe.value);
|
||||
if (isEditMode.value && !isSame && props.recipe?.slug !== undefined) {
|
||||
const save = window.confirm(
|
||||
i18n.tc("general.unsaved-changes"),
|
||||
);
|
||||
|
||||
if (save) {
|
||||
await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
||||
}
|
||||
}
|
||||
deactivateNavigationWarning();
|
||||
toggleCookMode()
|
||||
|
||||
clearPageState(props.recipe.slug || "");
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
const hasLinkedIngredients = computed(() => {
|
||||
return props.recipe.recipeInstructions.some((step) => step.ingredientReferences && step.ingredientReferences.length > 0);
|
||||
})
|
||||
/** =============================================================
|
||||
* Set State onMounted
|
||||
*/
|
||||
|
||||
type BooleanString = "true" | "false" | "";
|
||||
|
||||
const edit = useRouteQuery<BooleanString>("edit", "");
|
||||
|
||||
onMounted(() => {
|
||||
if (edit.value === "true") {
|
||||
setMode(PageMode.EDIT);
|
||||
}
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
* Recipe Save Delete
|
||||
*/
|
||||
|
||||
async function saveRecipe() {
|
||||
const { data } = await api.recipes.updateOne(props.recipe.slug, props.recipe);
|
||||
setMode(PageMode.VIEW);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const { data } = await api.recipes.deleteOne(props.recipe.slug);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** =============================================================
|
||||
* View Preferences
|
||||
*/
|
||||
const { $vuetify, i18n } = useContext();
|
||||
|
||||
const landscape = computed(() => {
|
||||
const preferLandscape = props.recipe.settings.landscapeView;
|
||||
const smallScreen = !$vuetify.breakpoint.smAndUp;
|
||||
|
||||
if (preferLandscape) {
|
||||
return true;
|
||||
} else if (smallScreen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
* Bulk Step Editor
|
||||
* TODO: Move to RecipePageInstructions component
|
||||
*/
|
||||
|
||||
function addStep(steps: Array<string> | null = null) {
|
||||
if (!props.recipe.recipeInstructions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (steps) {
|
||||
const cleanedSteps = steps.map((step) => {
|
||||
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
||||
});
|
||||
|
||||
props.recipe.recipeInstructions.push(...cleanedSteps);
|
||||
} else {
|
||||
props.recipe.recipeInstructions.push({ id: uuid4(), text: "", title: "", ingredientReferences: [] });
|
||||
}
|
||||
}
|
||||
|
||||
/** =============================================================
|
||||
* Meta Tags
|
||||
*/
|
||||
const { user } = usePageUser();
|
||||
|
||||
/** =============================================================
|
||||
* RecipeChip Clicked
|
||||
*/
|
||||
|
||||
function chipClicked(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
router.push(`/g/${groupSlug.value}?${itemType}=${item.id}`);
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isOwnGroup,
|
||||
api,
|
||||
scale: ref(1),
|
||||
EDITOR_OPTIONS,
|
||||
landscape,
|
||||
|
||||
pageMode,
|
||||
editMode,
|
||||
PageMode,
|
||||
EditorMode,
|
||||
isEditMode,
|
||||
isEditForm,
|
||||
isEditJSON,
|
||||
isCookMode,
|
||||
toggleCookMode,
|
||||
saveRecipe,
|
||||
deleteRecipe,
|
||||
addStep,
|
||||
hasLinkedIngredients,
|
||||
notLinkedIngredients,
|
||||
chipClicked,
|
||||
};
|
||||
},
|
||||
head: {},
|
||||
const router = useRouter();
|
||||
const api = useUserApi();
|
||||
const { setMode, isEditForm, isEditJSON, isCookMode, isEditMode, toggleCookMode }
|
||||
= usePageState(recipe.value.slug);
|
||||
const { deactivateNavigationWarning } = useNavigationWarning();
|
||||
const notLinkedIngredients = computed(() => {
|
||||
return recipe.value.recipeIngredient.filter((ingredient) => {
|
||||
return !recipe.value.recipeInstructions.some(step =>
|
||||
step.ingredientReferences?.map(ref => ref.referenceId).includes(ingredient.referenceId),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
* Recipe Snapshot on Mount
|
||||
* this is used to determine if the recipe has been changed since the last save
|
||||
* and prompts the user to save if they have unsaved changes.
|
||||
*/
|
||||
const originalRecipe = ref<Recipe | null>(null);
|
||||
|
||||
invoke(async () => {
|
||||
await until(recipe.value).not.toBeNull();
|
||||
originalRecipe.value = deepCopy(recipe.value);
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
const isSame = JSON.stringify(recipe.value) === JSON.stringify(originalRecipe.value);
|
||||
if (isEditMode.value && !isSame && recipe.value?.slug !== undefined) {
|
||||
const save = window.confirm(i18n.t("general.unsaved-changes"));
|
||||
|
||||
if (save) {
|
||||
await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
}
|
||||
}
|
||||
deactivateNavigationWarning();
|
||||
toggleCookMode();
|
||||
|
||||
clearPageState(recipe.value.slug || "");
|
||||
console.debug("reset RecipePage state during unmount");
|
||||
});
|
||||
const hasLinkedIngredients = computed(() => {
|
||||
return recipe.value.recipeInstructions.some(
|
||||
step => step.ingredientReferences && step.ingredientReferences.length > 0,
|
||||
);
|
||||
});
|
||||
/** =============================================================
|
||||
* Set State onMounted
|
||||
*/
|
||||
|
||||
type BooleanString = "true" | "false" | "";
|
||||
|
||||
const edit = useRouteQuery<BooleanString>("edit", "");
|
||||
|
||||
onMounted(() => {
|
||||
if (edit.value === "true") {
|
||||
setMode(PageMode.EDIT);
|
||||
}
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
* Recipe Save Delete
|
||||
*/
|
||||
|
||||
async function saveRecipe() {
|
||||
const { data } = await api.recipes.updateOne(recipe.value.slug, recipe.value);
|
||||
setMode(PageMode.VIEW);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/` + data.slug);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const { data } = await api.recipes.deleteOne(recipe.value.slug);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** =============================================================
|
||||
* View Preferences
|
||||
*/
|
||||
const landscape = computed(() => {
|
||||
const preferLandscape = recipe.value.settings.landscapeView;
|
||||
const smallScreen = !$vuetify.display.smAndUp.value;
|
||||
|
||||
if (preferLandscape) {
|
||||
return true;
|
||||
}
|
||||
else if (smallScreen) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
/** =============================================================
|
||||
* Bulk Step Editor
|
||||
* TODO: Move to RecipePageInstructions component
|
||||
*/
|
||||
|
||||
function addStep(steps: Array<string> | null = null) {
|
||||
if (!recipe.value.recipeInstructions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (steps) {
|
||||
const cleanedSteps = steps.map((step) => {
|
||||
return { id: uuid4(), text: step, title: "", ingredientReferences: [] };
|
||||
});
|
||||
|
||||
recipe.value.recipeInstructions.push(...cleanedSteps);
|
||||
}
|
||||
else {
|
||||
recipe.value.recipeInstructions.push({
|
||||
id: uuid4(),
|
||||
text: "",
|
||||
title: "",
|
||||
summary: "",
|
||||
ingredientReferences: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** =============================================================
|
||||
* RecipeChip Clicked
|
||||
*/
|
||||
|
||||
function chipClicked(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
router.push(`/g/${groupSlug.value}?${itemType}=${item.id}`);
|
||||
}
|
||||
|
||||
const scale = ref(1);
|
||||
|
||||
// expose to template
|
||||
// (all variables used in template are top-level in <script setup>)
|
||||
</script>
|
||||
|
||||
<style lang="css">
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.list-group-item i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
@ -6,44 +6,73 @@
|
|||
</v-icon>
|
||||
{{ $t("recipe.comments") }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<div v-if="user.id" class="d-flex flex-column">
|
||||
<div class="d-flex mt-3" style="gap: 10px">
|
||||
<UserAvatar :tooltip="false" size="40" :user-id="user.id" />
|
||||
<v-divider class="mx-2" />
|
||||
<div
|
||||
v-if="user.id"
|
||||
class="d-flex flex-column"
|
||||
>
|
||||
<div
|
||||
class="d-flex mt-3"
|
||||
style="gap: 10px"
|
||||
>
|
||||
<UserAvatar
|
||||
:tooltip="false"
|
||||
size="40"
|
||||
:user-id="user.id"
|
||||
/>
|
||||
|
||||
<v-textarea
|
||||
v-model="comment"
|
||||
hide-details=""
|
||||
dense
|
||||
hide-details
|
||||
density="compact"
|
||||
single-line
|
||||
outlined
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
rows="2"
|
||||
:placeholder="$t('recipe.join-the-conversation')"
|
||||
>
|
||||
</v-textarea>
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto mt-1">
|
||||
<BaseButton small :disabled="!comment" @click="submitComment">
|
||||
<template #icon>{{ $globals.icons.check }}</template>
|
||||
<BaseButton
|
||||
size="small"
|
||||
:disabled="!comment"
|
||||
@click="submitComment"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.check }}
|
||||
</template>
|
||||
{{ $t("general.submit") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="comment in recipe.comments" :key="comment.id" class="d-flex my-2" style="gap: 10px">
|
||||
<UserAvatar :tooltip="false" size="40" :user-id="comment.userId" />
|
||||
<v-card outlined class="flex-grow-1">
|
||||
<div
|
||||
v-for="recipeComment in recipe.comments"
|
||||
:key="recipeComment.id"
|
||||
class="d-flex my-2"
|
||||
style="gap: 10px"
|
||||
>
|
||||
<UserAvatar
|
||||
:tooltip="false"
|
||||
size="40"
|
||||
:user-id="recipeComment.userId"
|
||||
/>
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="flex-grow-1"
|
||||
>
|
||||
<v-card-text class="pa-3 pb-0">
|
||||
<p class="">{{ comment.user.fullName }} • {{ $d(Date.parse(comment.createdAt), "medium") }}</p>
|
||||
<SafeMarkdown :source="comment.text" />
|
||||
<p class="">
|
||||
{{ recipeComment.user.fullName }} • {{ $d(Date.parse(recipeComment.createdAt), "medium") }}
|
||||
</p>
|
||||
<SafeMarkdown :source="recipeComment.text" />
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end mt-0 pt-0">
|
||||
<v-btn
|
||||
v-if="user.id == comment.user.id || user.admin"
|
||||
v-if="user.id == recipeComment.user.id || user.admin"
|
||||
color="error"
|
||||
text
|
||||
x-small
|
||||
@click="deleteComment(comment.id)"
|
||||
variant="text"
|
||||
size="x-small"
|
||||
@click="deleteComment(recipeComment.id)"
|
||||
>
|
||||
{{ $t("general.delete") }}
|
||||
</v-btn>
|
||||
|
@ -53,58 +82,37 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, reactive } from "@nuxtjs/composition-api";
|
||||
<script lang="ts" setup>
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
UserAvatar,
|
||||
SafeMarkdown
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const api = useUserApi();
|
||||
const { user } = usePageUser();
|
||||
const comment = ref("");
|
||||
|
||||
const { user } = usePageUser();
|
||||
async function submitComment() {
|
||||
const { data } = await api.recipes.comments.createOne({
|
||||
recipeId: recipe.value.id,
|
||||
text: comment.value,
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
comment: "",
|
||||
});
|
||||
if (data) {
|
||||
recipe.value.comments.push(data);
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
const { data } = await api.recipes.comments.createOne({
|
||||
recipeId: props.recipe.id,
|
||||
text: state.comment,
|
||||
});
|
||||
comment.value = "";
|
||||
}
|
||||
|
||||
if (data) {
|
||||
// @ts-ignore username is always populated here
|
||||
props.recipe.comments.push(data);
|
||||
}
|
||||
async function deleteComment(id: string) {
|
||||
const { response } = await api.recipes.comments.deleteOne(id);
|
||||
|
||||
state.comment = "";
|
||||
}
|
||||
|
||||
async function deleteComment(id: string) {
|
||||
const { response } = await api.recipes.comments.deleteOne(id);
|
||||
|
||||
if (response?.status === 200) {
|
||||
props.recipe.comments = props.recipe.comments.filter((comment) => comment.id !== id);
|
||||
}
|
||||
}
|
||||
|
||||
return { api, ...toRefs(state), submitComment, deleteComment, user };
|
||||
},
|
||||
});
|
||||
if (response?.status === 200) {
|
||||
recipe.value.comments = recipe.value.comments.filter(comment => comment.id !== id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,28 +1,44 @@
|
|||
<template>
|
||||
<div class="d-flex justify-start align-top py-2">
|
||||
<RecipeImageUploadBtn class="my-1" :slug="recipe.slug" @upload="uploadImage" @refresh="imageKey++" />
|
||||
<RecipeImageUploadBtn
|
||||
class="my-1"
|
||||
:slug="recipe.slug"
|
||||
@upload="uploadImage"
|
||||
@refresh="imageKey++"
|
||||
/>
|
||||
<RecipeSettingsMenu
|
||||
v-model="recipe.settings"
|
||||
class="my-1 mx-1"
|
||||
:value="recipe.settings"
|
||||
:is-owner="recipe.userId == user.id"
|
||||
@upload="uploadImage"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-container class="py-0" style="width: 40%;">
|
||||
<v-container
|
||||
class="py-0"
|
||||
style="width: 40%;"
|
||||
>
|
||||
<v-select
|
||||
v-model="recipe.userId"
|
||||
:items="allUsers"
|
||||
item-text="fullName"
|
||||
item-title="fullName"
|
||||
item-value="id"
|
||||
:label="$tc('general.owner')"
|
||||
:label="$t('general.owner')"
|
||||
hide-details
|
||||
:disabled="!canEditOwner"
|
||||
variant="underlined"
|
||||
>
|
||||
<template #prepend>
|
||||
<UserAvatar :user-id="recipe.userId" :tooltip="false" />
|
||||
<UserAvatar
|
||||
:user-id="recipe.userId"
|
||||
:tooltip="false"
|
||||
/>
|
||||
</template>
|
||||
</v-select>
|
||||
<v-card-text v-if="ownerHousehold" class="pa-0 d-flex" style="align-items: flex-end;">
|
||||
<v-card-text
|
||||
v-if="ownerHousehold"
|
||||
class="pa-0 d-flex"
|
||||
style="align-items: flex-end;"
|
||||
>
|
||||
<v-spacer />
|
||||
<v-icon>{{ $globals.icons.household }}</v-icon>
|
||||
<span class="pl-1">{{ ownerHousehold.name }}</span>
|
||||
|
@ -31,11 +47,11 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import RecipeImageUploadBtn from "~/components/Domain/Recipe/RecipeImageUploadBtn.vue";
|
||||
import RecipeSettingsMenu from "~/components/Domain/Recipe/RecipeSettingsMenu.vue";
|
||||
|
@ -43,57 +59,34 @@ import { useUserStore } from "~/composables/store/use-user-store";
|
|||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
import { useHouseholdStore } from "~/composables/store";
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeImageUploadBtn,
|
||||
RecipeSettingsMenu,
|
||||
UserAvatar,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { user } = usePageUser();
|
||||
const api = useUserApi();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const canEditOwner = computed(() => {
|
||||
return user.id === props.recipe.userId || user.admin;
|
||||
})
|
||||
const { user } = usePageUser();
|
||||
const api = useUserApi();
|
||||
const { imageKey } = usePageState(recipe.value.slug);
|
||||
|
||||
const { store: allUsers } = useUserStore();
|
||||
const { store: households } = useHouseholdStore();
|
||||
const ownerHousehold = computed(() => {
|
||||
const owner = allUsers.value.find((u) => u.id === props.recipe.userId);
|
||||
if (!owner) {
|
||||
return null;
|
||||
};
|
||||
|
||||
return households.value.find((h) => h.id === owner.householdId);
|
||||
});
|
||||
|
||||
async function uploadImage(fileObject: File) {
|
||||
if (!props.recipe || !props.recipe.slug) {
|
||||
return;
|
||||
}
|
||||
const newVersion = await api.recipes.updateImage(props.recipe.slug, fileObject);
|
||||
if (newVersion?.data?.image) {
|
||||
props.recipe.image = newVersion.data.image;
|
||||
}
|
||||
imageKey.value++;
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
canEditOwner,
|
||||
uploadImage,
|
||||
imageKey,
|
||||
allUsers,
|
||||
ownerHousehold,
|
||||
};
|
||||
},
|
||||
const canEditOwner = computed(() => {
|
||||
return user.id === recipe.value.userId || user.admin;
|
||||
});
|
||||
|
||||
const { store: allUsers } = useUserStore();
|
||||
const { store: households } = useHouseholdStore();
|
||||
const ownerHousehold = computed(() => {
|
||||
const owner = allUsers.value.find(u => u.id === recipe.value.userId);
|
||||
if (!owner) {
|
||||
return null;
|
||||
}
|
||||
return households.value.find(h => h.id === owner.householdId);
|
||||
});
|
||||
|
||||
async function uploadImage(fileObject: File) {
|
||||
if (!recipe.value || !recipe.value.slug) {
|
||||
return;
|
||||
}
|
||||
const newVersion = await api.recipes.updateImage(recipe.value.slug, fileObject);
|
||||
if (newVersion?.data?.image) {
|
||||
recipe.value.image = newVersion.data.image;
|
||||
}
|
||||
imageKey.value++;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -5,35 +5,54 @@
|
|||
v-if="isEditForm"
|
||||
v-model="recipe.orgURL"
|
||||
class="mt-10"
|
||||
variant="underlined"
|
||||
:label="$t('recipe.original-url')"
|
||||
></v-text-field>
|
||||
/>
|
||||
<v-btn
|
||||
v-else-if="recipe.orgURL && !isCookMode"
|
||||
dense
|
||||
small
|
||||
:hover="false"
|
||||
type="label"
|
||||
:ripple="false"
|
||||
elevation="0"
|
||||
variant="flat"
|
||||
:href="recipe.orgURL"
|
||||
color="secondary darken-1"
|
||||
color="secondary-darken-1"
|
||||
target="_blank"
|
||||
class="rounded-sm mr-n2"
|
||||
class="mr-n2"
|
||||
size="small"
|
||||
>
|
||||
{{ $t("recipe.original-url") }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
<AdvancedOnly>
|
||||
<v-card v-if="isEditForm" flat class="mb-2 mx-n2">
|
||||
<v-card-title> {{ $t('recipe.api-extras') }} </v-card-title>
|
||||
<v-divider class="ml-4"></v-divider>
|
||||
<v-card
|
||||
v-if="isEditForm"
|
||||
flat
|
||||
class="mb-2 mx-n2"
|
||||
>
|
||||
<v-card-title class="text-h5 font-weight-medium opacity-80">
|
||||
{{ $t('recipe.api-extras') }}
|
||||
</v-card-title>
|
||||
<v-divider class="ml-4" />
|
||||
<v-card-text>
|
||||
{{ $t('recipe.api-extras-description') }}
|
||||
<v-row v-for="(_, key) in recipe.extras" :key="key" class="mt-1">
|
||||
<v-row
|
||||
v-for="(_, key) in recipe.extras"
|
||||
:key="key"
|
||||
class="mt-1"
|
||||
>
|
||||
<v-col style="max-width: 400px;">
|
||||
<v-text-field v-model="recipe.extras[key]" dense :label="key">
|
||||
<v-text-field
|
||||
v-model="recipe.extras[key]"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
:label="key"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-btn color="error" icon class="mt-n4" @click="removeApiExtra(key)">
|
||||
<v-btn
|
||||
color="error"
|
||||
icon
|
||||
class="mt-n4"
|
||||
@click="removeApiExtra(key)"
|
||||
>
|
||||
<v-icon> {{ $globals.icons.delete }} </v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
@ -43,69 +62,58 @@
|
|||
</v-card-text>
|
||||
<v-card-actions class="d-flex ml-2 mt-n3">
|
||||
<div>
|
||||
<v-text-field v-model="apiNewKey" :label="$t('recipe.message-key')"></v-text-field>
|
||||
<v-text-field
|
||||
v-model="apiNewKey"
|
||||
min-width="200px"
|
||||
:label="$t('recipe.message-key')"
|
||||
variant="underlined"
|
||||
/>
|
||||
</div>
|
||||
<BaseButton create small class="ml-5" @click="createApiExtra" />
|
||||
<BaseButton
|
||||
create
|
||||
size="small"
|
||||
class="ml-5"
|
||||
@click="createApiExtra"
|
||||
/>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</AdvancedOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { isEditForm, isCookMode } = usePageState(props.recipe.slug);
|
||||
const apiNewKey = ref("");
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
function createApiExtra() {
|
||||
if (!props.recipe) {
|
||||
return;
|
||||
}
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const { isEditForm, isCookMode } = usePageState(recipe.value.slug);
|
||||
const apiNewKey = ref("");
|
||||
|
||||
if (!props.recipe.extras) {
|
||||
props.recipe.extras = {};
|
||||
}
|
||||
function createApiExtra() {
|
||||
if (!recipe.value) {
|
||||
return;
|
||||
}
|
||||
if (!recipe.value.extras) {
|
||||
recipe.value.extras = {};
|
||||
}
|
||||
// check for duplicate keys
|
||||
if (Object.keys(recipe.value.extras).includes(apiNewKey.value)) {
|
||||
return;
|
||||
}
|
||||
recipe.value.extras[apiNewKey.value] = "";
|
||||
apiNewKey.value = "";
|
||||
}
|
||||
|
||||
// check for duplicate keys
|
||||
if (Object.keys(props.recipe.extras).includes(apiNewKey.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.recipe.extras[apiNewKey.value] = "";
|
||||
|
||||
apiNewKey.value = "";
|
||||
}
|
||||
|
||||
function removeApiExtra(key: string | number) {
|
||||
if (!props.recipe) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.recipe.extras) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete props.recipe.extras[key];
|
||||
props.recipe.extras = { ...props.recipe.extras };
|
||||
}
|
||||
return {
|
||||
removeApiExtra,
|
||||
createApiExtra,
|
||||
apiNewKey,
|
||||
isEditForm,
|
||||
isCookMode,
|
||||
};
|
||||
},
|
||||
});
|
||||
function removeApiExtra(key: string | number) {
|
||||
if (!recipe.value) {
|
||||
return;
|
||||
}
|
||||
if (!recipe.value.extras) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
|
||||
delete recipe.value.extras[key];
|
||||
recipe.value.extras = { ...recipe.value.extras };
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<RecipePageInfoCard :recipe="recipe" :recipe-scale="recipeScale" :landscape="landscape" />
|
||||
<RecipePageInfoCard
|
||||
:recipe="recipe"
|
||||
:recipe-scale="recipeScale"
|
||||
:landscape="landscape"
|
||||
/>
|
||||
<v-divider />
|
||||
<RecipeActionMenu
|
||||
:recipe="recipe"
|
||||
|
@ -11,7 +15,7 @@
|
|||
:logged-in="isOwnGroup"
|
||||
:open="isEditMode"
|
||||
:recipe-id="recipe.id"
|
||||
class="ml-auto mt-n2 pb-4"
|
||||
class="ml-auto mt-n7 pb-4"
|
||||
@close="setMode(PageMode.VIEW)"
|
||||
@json="toggleEditMode()"
|
||||
@edit="setMode(PageMode.EDIT)"
|
||||
|
@ -23,17 +27,17 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useRecipePermissions } from "~/composables/recipes";
|
||||
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { usePageState, usePageUser, PageMode, EditorMode } from "~/composables/recipe-page/shared-state";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePageInfoCard,
|
||||
RecipeActionMenu,
|
||||
|
@ -52,8 +56,9 @@ export default defineComponent({
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["save", "delete"],
|
||||
setup(props) {
|
||||
const { $vuetify } = useContext();
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
|
@ -74,7 +79,7 @@ export default defineComponent({
|
|||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
|
@ -85,7 +90,7 @@ export default defineComponent({
|
|||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,24 +1,36 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
||||
<RecipePageInfoCardImage v-if="landscape" :recipe="recipe" />
|
||||
<RecipePageInfoCardImage
|
||||
v-if="landscape"
|
||||
:recipe="recipe"
|
||||
/>
|
||||
<v-card
|
||||
:width="landscape ? '100%' : '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">
|
||||
<v-card-text class="w-100">
|
||||
<v-card-title class="text-h5 font-weight-regular pa-0 d-flex flex-column align-center justify-center opacity-80">
|
||||
{{ recipe.name }}
|
||||
<RecipeRating :key="recipe.slug" :value="recipe.rating" :recipe-id="recipe.id" :slug="recipe.slug" />
|
||||
<RecipeRating
|
||||
:key="recipe.slug"
|
||||
:value="recipe.rating"
|
||||
:recipe-id="recipe.id"
|
||||
:slug="recipe.slug"
|
||||
/>
|
||||
</v-card-title>
|
||||
<v-divider class="my-2" />
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
<SafeMarkdown :source="recipe.description" class="my-3" />
|
||||
<v-divider v-if="recipe.description" />
|
||||
<v-container class="d-flex flex-row flex-wrap justify-center">
|
||||
<div class="mx-6">
|
||||
<v-row no-gutters>
|
||||
<v-col v-if="recipe.recipeYieldQuantity || recipe.recipeYield" cols="12" class="d-flex flex-wrap justify-center">
|
||||
<v-col
|
||||
v-if="recipe.recipeYieldQuantity || recipe.recipeYield"
|
||||
cols="12"
|
||||
class="d-flex flex-wrap justify-center"
|
||||
>
|
||||
<RecipeYield
|
||||
:yield-quantity="recipe.recipeYieldQuantity"
|
||||
:yield="recipe.recipeYield"
|
||||
|
@ -28,7 +40,10 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" class="d-flex flex-wrap justify-center">
|
||||
<v-col
|
||||
cols="12"
|
||||
class="d-flex flex-wrap justify-center"
|
||||
>
|
||||
<RecipeLastMade
|
||||
v-if="isOwnGroup"
|
||||
:recipe="recipe"
|
||||
|
@ -49,22 +64,27 @@
|
|||
</v-container>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<RecipePageInfoCardImage v-if="!landscape" :recipe="recipe" max-width="50%" class="my-auto" />
|
||||
<RecipePageInfoCardImage
|
||||
v-if="!landscape"
|
||||
:recipe="recipe"
|
||||
max-width="50%"
|
||||
class="my-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import RecipeYield from "~/components/Domain/Recipe/RecipeYield.vue";
|
||||
import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCardImage.vue";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
export default defineComponent({
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeRating,
|
||||
RecipeLastMade,
|
||||
|
@ -87,15 +107,11 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
setup() {
|
||||
const { $vuetify } = useContext();
|
||||
const useMobile = computed(() => $vuetify.breakpoint.smAndDown);
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
useMobile,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
:key="imageKey"
|
||||
:max-width="maxWidth"
|
||||
min-height="50"
|
||||
cover
|
||||
width="100%"
|
||||
:height="hideImage ? undefined : imageHeight"
|
||||
:src="recipeImageUrl"
|
||||
class="d-print-none"
|
||||
|
@ -11,13 +13,13 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
export default defineComponent({
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
|
@ -29,7 +31,7 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $vuetify } = useContext();
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
|
@ -44,7 +46,7 @@ export default defineComponent({
|
|||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
|
@ -55,7 +57,7 @@ export default defineComponent({
|
|||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -64,6 +66,6 @@ export default defineComponent({
|
|||
hideImage,
|
||||
imageHeight,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -5,103 +5,117 @@
|
|||
class="my-3"
|
||||
:label="$t('recipe.recipe-name')"
|
||||
:rules="[validators.required]"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
/>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeServings"
|
||||
:model-value="recipeServings"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
density="compact"
|
||||
:label="$t('recipe.servings')"
|
||||
@input="validateInput($event, 'recipeServings')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeServings')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="3">
|
||||
<v-text-field
|
||||
v-model="recipeYieldQuantity"
|
||||
:model-value="recipeYieldQuantity"
|
||||
type="number"
|
||||
:min="0"
|
||||
hide-spin-buttons
|
||||
dense
|
||||
density="compact"
|
||||
:label="$t('recipe.yield')"
|
||||
@input="validateInput($event, 'recipeYieldQuantity')"
|
||||
variant="underlined"
|
||||
@update:model-value="validateInput($event, 'recipeYieldQuantity')"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-text-field
|
||||
v-model="recipe.recipeYield"
|
||||
dense
|
||||
:label="$t('recipe.yield-text')"
|
||||
/>
|
||||
v-model="recipe.recipeYield"
|
||||
density="compact"
|
||||
:label="$t('recipe.yield-text')"
|
||||
variant="underlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<div class="d-flex flex-wrap" style="gap: 1rem">
|
||||
<v-text-field v-model="recipe.totalTime" :label="$t('recipe.total-time')" />
|
||||
<v-text-field v-model="recipe.prepTime" :label="$t('recipe.prep-time')" />
|
||||
<v-text-field v-model="recipe.performTime" :label="$t('recipe.perform-time')" />
|
||||
<div
|
||||
class="d-flex flex-wrap"
|
||||
style="gap: 1rem"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="recipe.totalTime"
|
||||
:label="$t('recipe.total-time')"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="recipe.prepTime"
|
||||
:label="$t('recipe.prep-time')"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="recipe.performTime"
|
||||
:label="$t('recipe.perform-time')"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
/>
|
||||
</div>
|
||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')" />
|
||||
<v-textarea
|
||||
v-model="recipe.description"
|
||||
auto-grow
|
||||
min-height="100"
|
||||
:label="$t('recipe.description')"
|
||||
density="compact"
|
||||
variant="underlined"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
<script setup lang="ts">
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
|
||||
const recipeServings = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeServings;
|
||||
},
|
||||
setup(props) {
|
||||
const recipeServings = computed<number>({
|
||||
get() {
|
||||
return props.recipe.recipeServings;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeServings");
|
||||
},
|
||||
});
|
||||
|
||||
const recipeYieldQuantity = computed<number>({
|
||||
get() {
|
||||
return props.recipe.recipeYieldQuantity;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeYieldQuantity");
|
||||
},
|
||||
});
|
||||
|
||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||
if (!value) {
|
||||
props.recipe[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||
if (isNaN(number) || number <= 0) {
|
||||
props.recipe[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
props.recipe[property] = number;
|
||||
}
|
||||
|
||||
return {
|
||||
validators,
|
||||
recipeServings,
|
||||
recipeYieldQuantity,
|
||||
validateInput,
|
||||
};
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeServings");
|
||||
},
|
||||
});
|
||||
|
||||
const recipeYieldQuantity = computed<number>({
|
||||
get() {
|
||||
return recipe.value.recipeYieldQuantity;
|
||||
},
|
||||
set(val) {
|
||||
validateInput(val.toString(), "recipeYieldQuantity");
|
||||
},
|
||||
});
|
||||
|
||||
function validateInput(value: string | null, property: "recipeServings" | "recipeYieldQuantity") {
|
||||
if (!value) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const number = parseFloat(value.replace(/[^0-9.]/g, ""));
|
||||
if (isNaN(number) || number <= 0) {
|
||||
recipe.value[property] = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
recipe.value[property] = number;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="mb-4">{{ $t("recipe.ingredients") }}</h2>
|
||||
<draggable
|
||||
<h2 class="mb-4 text-h5 font-weight-medium opacity-80">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</h2>
|
||||
<VueDraggable
|
||||
v-if="recipe.recipeIngredient.length > 0"
|
||||
v-model="recipe.recipeIngredient"
|
||||
handle=".handle"
|
||||
delay="250"
|
||||
:delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
|
@ -16,7 +19,9 @@
|
|||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
>
|
||||
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
|
||||
<TransitionGroup
|
||||
type="transition"
|
||||
>
|
||||
<RecipeIngredientEditor
|
||||
v-for="(ingredient, index) in recipe.recipeIngredient"
|
||||
:key="ingredient.referenceId"
|
||||
|
@ -25,21 +30,29 @@
|
|||
:disable-amount="recipe.settings.disableAmount"
|
||||
@delete="recipe.recipeIngredient.splice(index, 1)"
|
||||
@insert-above="insertNewIngredient(index)"
|
||||
@insert-below="insertNewIngredient(index+1)"
|
||||
@insert-below="insertNewIngredient(index + 1)"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
</draggable>
|
||||
<v-skeleton-loader v-else boilerplate elevation="2" type="list-item"> </v-skeleton-loader>
|
||||
</VueDraggable>
|
||||
<v-skeleton-loader
|
||||
v-else
|
||||
boilerplate
|
||||
elevation="2"
|
||||
type="list-item"
|
||||
/>
|
||||
<div class="d-flex flex-wrap justify-center justify-sm-end mt-3">
|
||||
<v-tooltip top color="accent">
|
||||
<template #activator="{ on, attrs }">
|
||||
<span v-on="on">
|
||||
<v-tooltip
|
||||
top
|
||||
color="accent"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<span>
|
||||
<BaseButton
|
||||
class="mb-1"
|
||||
:disabled="recipe.settings.disableAmount || hasFoodOrUnit"
|
||||
color="accent"
|
||||
:to="`/g/${groupSlug}/r/${recipe.slug}/ingredient-parser`"
|
||||
v-bind="attrs"
|
||||
v-bind="props"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.foods }}
|
||||
|
@ -50,124 +63,106 @@
|
|||
</template>
|
||||
<span>{{ parserToolTip }}</span>
|
||||
</v-tooltip>
|
||||
<RecipeDialogBulkAdd class="mx-1 mb-1" @bulk-data="addIngredient" />
|
||||
<BaseButton class="mb-1" @click="addIngredient" > {{ $t("general.add") }} </BaseButton>
|
||||
<RecipeDialogBulkAdd
|
||||
class="mx-1 mb-1"
|
||||
@bulk-data="addIngredient"
|
||||
/>
|
||||
<BaseButton
|
||||
class="mb-1"
|
||||
@click="addIngredient"
|
||||
>
|
||||
{{ $t("general.add") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
<script setup lang="ts">
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeIngredientEditor from "~/components/Domain/Recipe/RecipeIngredientEditor.vue";
|
||||
import RecipeDialogBulkAdd from "~/components/Domain/Recipe/RecipeDialogBulkAdd.vue";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
export default defineComponent({
|
||||
components: {
|
||||
draggable,
|
||||
RecipeDialogBulkAdd,
|
||||
RecipeIngredientEditor,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { user } = usePageUser();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { $auth, i18n } = useContext();
|
||||
|
||||
const drag = ref(false);
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const drag = ref(false);
|
||||
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
if (!props.recipe) {
|
||||
return false;
|
||||
}
|
||||
if (props.recipe.recipeIngredient) {
|
||||
for (const ingredient of props.recipe.recipeIngredient) {
|
||||
if (ingredient.food || ingredient.unit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const parserToolTip = computed(() => {
|
||||
if (props.recipe.settings.disableAmount) {
|
||||
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
|
||||
} else if (hasFoodOrUnit.value) {
|
||||
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
|
||||
}
|
||||
return i18n.t("recipe.parse-ingredients");
|
||||
});
|
||||
|
||||
function addIngredient(ingredients: Array<string> | null = null) {
|
||||
if (ingredients?.length) {
|
||||
const newIngredients = ingredients.map((x) => {
|
||||
return {
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: x,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
};
|
||||
});
|
||||
|
||||
if (newIngredients) {
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
props.recipe.recipeIngredient.push(...newIngredients);
|
||||
}
|
||||
} else {
|
||||
props.recipe.recipeIngredient.push({
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: "",
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
unit: undefined,
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
food: undefined,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
});
|
||||
const hasFoodOrUnit = computed(() => {
|
||||
if (!recipe.value) {
|
||||
return false;
|
||||
}
|
||||
if (recipe.value.recipeIngredient) {
|
||||
for (const ingredient of recipe.value.recipeIngredient) {
|
||||
if (ingredient.food || ingredient.unit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function insertNewIngredient(dest: number) {
|
||||
props.recipe.recipeIngredient.splice(dest, 0, {
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: "",
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
unit: undefined,
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
food: undefined,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
groupSlug,
|
||||
addIngredient,
|
||||
parserToolTip,
|
||||
hasFoodOrUnit,
|
||||
imageKey,
|
||||
drag,
|
||||
insertNewIngredient,
|
||||
};
|
||||
},
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const parserToolTip = computed(() => {
|
||||
if (recipe.value.settings.disableAmount) {
|
||||
return i18n.t("recipe.enable-ingredient-amounts-to-use-this-feature");
|
||||
}
|
||||
else if (hasFoodOrUnit.value) {
|
||||
return i18n.t("recipe.recipes-with-units-or-foods-defined-cannot-be-parsed");
|
||||
}
|
||||
return i18n.t("recipe.parse-ingredients");
|
||||
});
|
||||
|
||||
function addIngredient(ingredients: Array<string> | null = null) {
|
||||
if (ingredients?.length) {
|
||||
const newIngredients = ingredients.map((x) => {
|
||||
return {
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: x,
|
||||
unit: undefined,
|
||||
food: undefined,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
};
|
||||
});
|
||||
|
||||
if (newIngredients) {
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
recipe.value.recipeIngredient.push(...newIngredients);
|
||||
}
|
||||
}
|
||||
else {
|
||||
recipe.value.recipeIngredient.push({
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: "",
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
unit: undefined,
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
food: undefined,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function insertNewIngredient(dest: number) {
|
||||
recipe.value.recipeIngredient.splice(dest, 0, {
|
||||
referenceId: uuid4(),
|
||||
title: "",
|
||||
note: "",
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
unit: undefined,
|
||||
// @ts-expect-error - prop can be null-type by NoUndefinedField type forces it to be set
|
||||
food: undefined,
|
||||
disableAmount: true,
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -7,38 +7,47 @@
|
|||
:is-cook-mode="isCookMode"
|
||||
/>
|
||||
<div v-if="!isEditMode && recipe.tools && recipe.tools.length > 0">
|
||||
<h2 class="mb-2 mt-4">{{ $t('tool.required-tools') }}</h2>
|
||||
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
|
||||
<v-checkbox
|
||||
v-model="recipeTools[index].onHand"
|
||||
hide-details
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
@change="updateTool(index)"
|
||||
<h2 class="mt-4 text-h5 font-weight-medium opacity-80">
|
||||
{{ $t('tool.required-tools') }}
|
||||
</h2>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(tool, index) in recipe.tools"
|
||||
:key="index"
|
||||
density="compact"
|
||||
>
|
||||
</v-checkbox>
|
||||
<v-list-item-content>
|
||||
{{ tool.name }}
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
<template #prepend>
|
||||
<v-checkbox
|
||||
v-model="recipeTools[index].onHand"
|
||||
hide-details
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
@change="updateTool(index)"
|
||||
/>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ tool.name }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { useToolStore } from "~/composables/store";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||
|
||||
interface RecipeToolWithOnHand extends RecipeTool {
|
||||
onHand: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeIngredients,
|
||||
},
|
||||
|
@ -54,7 +63,7 @@ export default defineComponent({
|
|||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
@ -65,14 +74,15 @@ export default defineComponent({
|
|||
|
||||
const recipeTools = computed(() => {
|
||||
if (!(user.householdSlug && toolStore)) {
|
||||
return props.recipe.tools.map((tool) => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||
} else {
|
||||
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
|
||||
}
|
||||
else {
|
||||
return props.recipe.tools.map((tool) => {
|
||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
function updateTool(index: number) {
|
||||
if (user.id && user.householdSlug && toolStore) {
|
||||
|
@ -80,15 +90,18 @@ export default defineComponent({
|
|||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
if (!tool.householdsWithTool) {
|
||||
tool.householdsWithTool = [user.householdSlug];
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
tool.householdsWithTool.push(user.householdSlug);
|
||||
}
|
||||
} else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
tool.householdsWithTool = tool.householdsWithTool.filter((household) => household !== user.householdSlug);
|
||||
}
|
||||
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
||||
}
|
||||
|
||||
toolStore.actions.updateOne(tool);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
console.log("no user, skipping server update");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,21 +1,34 @@
|
|||
<template>
|
||||
<section @keyup.ctrl.90="undoMerge">
|
||||
<section @keyup.ctrl.z="undoMerge">
|
||||
<!-- Ingredient Link Editor -->
|
||||
<v-dialog v-if="dialog" v-model="dialog" width="600">
|
||||
<v-dialog
|
||||
v-if="dialog"
|
||||
v-model="dialog"
|
||||
width="600"
|
||||
>
|
||||
<v-card :ripple="false">
|
||||
<v-app-bar dark color="primary" class="mt-n1 mb-3">
|
||||
<v-icon large left>
|
||||
<v-sheet
|
||||
color="primary"
|
||||
class="mt-n1 mb-3 pa-3 d-flex align-center"
|
||||
style="border-radius: 6px; width: 100%;"
|
||||
>
|
||||
<v-icon
|
||||
size="large"
|
||||
start
|
||||
>
|
||||
{{ $globals.icons.link }}
|
||||
</v-icon>
|
||||
<v-toolbar-title class="headline"> {{ $t("recipe.ingredient-linker") }} </v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
</v-app-bar>
|
||||
<v-toolbar-title class="headline">
|
||||
{{ $t("recipe.ingredient-linker") }}
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
</v-sheet>
|
||||
|
||||
<v-card-text class="pt-4">
|
||||
<p>
|
||||
{{ activeText }}
|
||||
</p>
|
||||
<v-divider class="mb-4"></v-divider>
|
||||
<v-divider class="mb-4" />
|
||||
<v-checkbox
|
||||
v-for="ing in unusedIngredients"
|
||||
:key="ing.referenceId"
|
||||
|
@ -29,7 +42,9 @@
|
|||
</v-checkbox>
|
||||
|
||||
<template v-if="usedIngredients.length > 0">
|
||||
<h4 class="py-3 ml-1">{{ $t("recipe.linked-to-other-step") }}</h4>
|
||||
<h4 class="py-3 ml-1">
|
||||
{{ $t("recipe.linked-to-other-step") }}
|
||||
</h4>
|
||||
<v-checkbox
|
||||
v-for="ing in usedIngredients"
|
||||
:key="ing.referenceId"
|
||||
|
@ -44,19 +59,38 @@
|
|||
</template>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<BaseButton cancel @click="dialog = false"> </BaseButton>
|
||||
<v-spacer></v-spacer>
|
||||
<BaseButton
|
||||
cancel
|
||||
@click="dialog = false"
|
||||
/>
|
||||
<v-spacer />
|
||||
<div class="d-flex flex-wrap justify-end">
|
||||
<BaseButton class="my-1" color="info" @click="autoSetReferences">
|
||||
<template #icon> {{ $globals.icons.robot }}</template>
|
||||
<BaseButton
|
||||
class="my-1"
|
||||
color="info"
|
||||
@click="autoSetReferences"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.robot }}
|
||||
</template>
|
||||
{{ $t("recipe.auto") }}
|
||||
</BaseButton>
|
||||
<BaseButton class="ml-2 my-1" save @click="setIngredientIds"> </BaseButton>
|
||||
<BaseButton v-if="availableNextStep" class="ml-2 my-1" @click="saveAndOpenNextLinkIngredients">
|
||||
<template #icon> {{ $globals.icons.forward }}</template>
|
||||
<BaseButton
|
||||
class="ml-2 my-1"
|
||||
save
|
||||
@click="setIngredientIds"
|
||||
/>
|
||||
<BaseButton
|
||||
v-if="availableNextStep"
|
||||
class="ml-2 my-1"
|
||||
@click="saveAndOpenNextLinkIngredients"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.forward }}
|
||||
</template>
|
||||
{{ $t("recipe.nextStep") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
@ -65,169 +99,200 @@
|
|||
</v-dialog>
|
||||
|
||||
<div class="d-flex justify-space-between justify-start">
|
||||
<h2 v-if="!isCookMode" class="mb-4 mt-1">{{ $t("recipe.instructions") }}</h2>
|
||||
<BaseButton v-if="!isEditForm && !isCookMode" minor cancel color="primary" @click="toggleCookMode()">
|
||||
<h2
|
||||
v-if="!isCookMode"
|
||||
class="mt-1 text-h5 font-weight-medium opacity-80"
|
||||
>
|
||||
{{ $t("recipe.instructions") }}
|
||||
</h2>
|
||||
<BaseButton
|
||||
v-if="!isEditForm && !isCookMode"
|
||||
minor
|
||||
cancel
|
||||
color="primary"
|
||||
@click="toggleCookMode()"
|
||||
>
|
||||
<template #icon>
|
||||
{{ $globals.icons.primary }}
|
||||
</template>
|
||||
{{ $t("recipe.cook-mode") }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
<draggable
|
||||
<VueDraggable
|
||||
v-model="instructionList"
|
||||
:disabled="!isEditForm"
|
||||
:value="value"
|
||||
handle=".handle"
|
||||
delay="250"
|
||||
:delay="250"
|
||||
:delay-on-touch-only="true"
|
||||
v-bind="{
|
||||
animation: 200,
|
||||
group: 'recipe-instructions',
|
||||
ghostClass: 'ghost',
|
||||
}"
|
||||
@input="updateIndex"
|
||||
@start="drag = true"
|
||||
@end="drag = false"
|
||||
@end="onDragEnd"
|
||||
>
|
||||
<TransitionGroup type="transition" :name="!drag ? 'flip-list' : ''">
|
||||
<div v-for="(step, index) in value" :key="step.id" class="list-group-item">
|
||||
<v-app-bar
|
||||
<TransitionGroup
|
||||
type="transition"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in instructionList"
|
||||
:key="step.id!"
|
||||
class="list-group-item"
|
||||
>
|
||||
<v-sheet
|
||||
v-if="step.id && showTitleEditor[step.id]"
|
||||
class="primary mt-6"
|
||||
style="cursor: pointer"
|
||||
dark
|
||||
dense
|
||||
rounded
|
||||
color="primary"
|
||||
class="mt-6 mb-2 d-flex align-center"
|
||||
:class="isEditForm ? 'pa-2' : 'pa-3'"
|
||||
style="border-radius: 6px; cursor: pointer; width: 100%;"
|
||||
@click="toggleCollapseSection(index)"
|
||||
>
|
||||
<v-toolbar-title v-if="!isEditForm" class="headline">
|
||||
<v-app-bar-title> {{ step.title }} </v-app-bar-title>
|
||||
</v-toolbar-title>
|
||||
<v-text-field
|
||||
v-if="isEditForm"
|
||||
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 }">
|
||||
<template v-if="isEditForm">
|
||||
<v-text-field
|
||||
v-model="step.title"
|
||||
class="pa-0"
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
:placeholder="$t('recipe.section-title')"
|
||||
bg-color="primary"
|
||||
hide-details
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-toolbar-title class="section-title-text">
|
||||
{{ step.title }}
|
||||
</v-toolbar-title>
|
||||
</template>
|
||||
</v-sheet>
|
||||
<v-hover v-slot="{ isHovering }">
|
||||
<v-card
|
||||
class="my-3"
|
||||
:class="[{ 'on-hover': hover }, isChecked(index)]"
|
||||
:elevation="hover ? 12 : 2"
|
||||
:class="[{ 'on-hover': isHovering }, isChecked(index)]"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
:ripple="false"
|
||||
@click="toggleDisabled(index)"
|
||||
>
|
||||
<v-card-title :class="{ 'pb-0': !isChecked(index) }">
|
||||
<v-text-field
|
||||
v-if="isEditForm"
|
||||
v-model="step.summary"
|
||||
class="headline handle"
|
||||
hide-details
|
||||
dense
|
||||
solo
|
||||
flat
|
||||
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="26">{{ $globals.icons.arrowUpDown }}</v-icon>
|
||||
<div class="d-flex align-center">
|
||||
<v-text-field
|
||||
v-if="isEditForm"
|
||||
v-model="step.summary"
|
||||
class="headline handle"
|
||||
hide-details
|
||||
density="compact"
|
||||
variant="solo"
|
||||
flat
|
||||
:placeholder="$t('recipe.step-index', { step: index + 1 })"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="26">
|
||||
{{ $globals.icons.arrowUpDown }}
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<span v-else>
|
||||
{{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }}
|
||||
</span>
|
||||
<template v-if="isEditForm">
|
||||
<div class="ml-auto">
|
||||
<BaseButtonGroup
|
||||
:large="false"
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $t('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.dotsVertical,
|
||||
text: '',
|
||||
event: 'open',
|
||||
children: [
|
||||
{
|
||||
text: $t('recipe.toggle-section'),
|
||||
event: 'toggle-section',
|
||||
},
|
||||
{
|
||||
text: $t('recipe.link-ingredients'),
|
||||
event: 'link-ingredients',
|
||||
},
|
||||
{
|
||||
text: $t('recipe.upload-image'),
|
||||
event: 'upload-image',
|
||||
},
|
||||
{
|
||||
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
|
||||
text: previewStates[index] ? $t('recipe.edit-markdown') : $t('markdown-editor.preview-markdown-button-label'),
|
||||
event: 'preview-step',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
text: $t('recipe.merge-above'),
|
||||
event: 'merge-above',
|
||||
},
|
||||
{
|
||||
text: $t('recipe.move-to-top'),
|
||||
event: 'move-to-top',
|
||||
},
|
||||
{
|
||||
text: $t('recipe.move-to-bottom'),
|
||||
event: 'move-to-bottom',
|
||||
},
|
||||
{
|
||||
text: $t('recipe.insert-above'),
|
||||
event: 'insert-above',
|
||||
},
|
||||
{
|
||||
text: $t('recipe.insert-below'),
|
||||
event: 'insert-below',
|
||||
},
|
||||
],
|
||||
},
|
||||
]"
|
||||
@merge-above="mergeAbove(index - 1, index)"
|
||||
@move-to-top="moveTo('top', index)"
|
||||
@move-to-bottom="moveTo('bottom', index)"
|
||||
@insert-above="insert(index)"
|
||||
@insert-below="insert(index + 1)"
|
||||
@toggle-section="toggleShowTitle(step.id!)"
|
||||
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
|
||||
@preview-step="togglePreviewState(index)"
|
||||
@upload-image="openImageUpload(index)"
|
||||
@delete="instructionList.splice(index, 1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</v-text-field>
|
||||
<span v-else>
|
||||
{{ step.summary ? step.summary : $t("recipe.step-index", { step: index + 1 }) }}
|
||||
</span>
|
||||
<template v-if="isEditForm">
|
||||
<div class="ml-auto">
|
||||
<BaseButtonGroup
|
||||
:large="false"
|
||||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.delete,
|
||||
text: $tc('general.delete'),
|
||||
event: 'delete',
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.dotsVertical,
|
||||
text: '',
|
||||
event: 'open',
|
||||
children: [
|
||||
{
|
||||
text: $tc('recipe.toggle-section'),
|
||||
event: 'toggle-section',
|
||||
},
|
||||
{
|
||||
text: $tc('recipe.link-ingredients'),
|
||||
event: 'link-ingredients',
|
||||
},
|
||||
{
|
||||
text: $tc('recipe.upload-image'),
|
||||
event: 'upload-image'
|
||||
},
|
||||
{
|
||||
icon: previewStates[index] ? $globals.icons.edit : $globals.icons.eye,
|
||||
text: previewStates[index] ? $tc('recipe.edit-markdown') : $tc('markdown-editor.preview-markdown-button-label'),
|
||||
event: 'preview-step',
|
||||
divider: true,
|
||||
},
|
||||
{
|
||||
text: $tc('recipe.merge-above'),
|
||||
event: 'merge-above',
|
||||
},
|
||||
{
|
||||
text: $tc('recipe.move-to-top'),
|
||||
event: 'move-to-top',
|
||||
},
|
||||
{
|
||||
text: $tc('recipe.move-to-bottom'),
|
||||
event: 'move-to-bottom',
|
||||
},
|
||||
{
|
||||
text: $tc('recipe.insert-above'),
|
||||
event: 'insert-above'
|
||||
},
|
||||
{
|
||||
text: $tc('recipe.insert-below'),
|
||||
event: 'insert-below'
|
||||
},
|
||||
],
|
||||
},
|
||||
]"
|
||||
@merge-above="mergeAbove(index - 1, index)"
|
||||
@move-to-top="moveTo('top', index)"
|
||||
@move-to-bottom="moveTo('bottom', index)"
|
||||
@insert-above="insert(index)"
|
||||
@insert-below="insert(index+1)"
|
||||
@toggle-section="toggleShowTitle(step.id)"
|
||||
@link-ingredients="openDialog(index, step.text, step.ingredientReferences)"
|
||||
@preview-step="togglePreviewState(index)"
|
||||
@upload-image="openImageUpload(index)"
|
||||
@delete="value.splice(index, 1)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<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-fade-transition>
|
||||
<v-icon
|
||||
v-show="isChecked(index)"
|
||||
size="24"
|
||||
class="ml-auto"
|
||||
color="success"
|
||||
>
|
||||
{{ $globals.icons.checkboxMarkedCircle }}
|
||||
</v-icon>
|
||||
</v-fade-transition>
|
||||
</div>
|
||||
</v-card-title>
|
||||
|
||||
<v-progress-linear v-if="isEditForm && loadingStates[index]" :active="true" :indeterminate="true" />
|
||||
<v-progress-linear
|
||||
v-if="isEditForm && loadingStates[index]"
|
||||
:active="true"
|
||||
:indeterminate="true"
|
||||
/>
|
||||
|
||||
<!-- Content -->
|
||||
<DropZone @drop="(f) => handleImageDrop(index, f)">
|
||||
<v-card-text
|
||||
v-if="isEditForm"
|
||||
@click="$emit('click-instruction-field', `${index}.text`)"
|
||||
v-if="isEditForm"
|
||||
@click="$emit('click-instruction-field', `${index}.text`)"
|
||||
>
|
||||
<MarkdownEditor
|
||||
v-model="value[index]['text']"
|
||||
v-model="instructionList[index]['text']"
|
||||
v-model:preview="previewStates[index]"
|
||||
class="mb-2"
|
||||
:preview.sync="previewStates[index]"
|
||||
:display-preview="false"
|
||||
:textarea="{
|
||||
hint: $t('recipe.attach-images-hint'),
|
||||
|
@ -236,14 +301,16 @@
|
|||
/>
|
||||
<RecipeIngredientHtml
|
||||
v-for="ing in step.ingredientReferences"
|
||||
:key="ing.referenceId"
|
||||
:markup="getIngredientByRefId(ing.referenceId)"
|
||||
:key="ing.referenceId!"
|
||||
:markup="getIngredientByRefId(ing.referenceId!)"
|
||||
/>
|
||||
</v-card-text>
|
||||
</DropZone>
|
||||
<v-expand-transition>
|
||||
<div v-show="!isChecked(index) && !isEditForm" class="m-0 p-0">
|
||||
|
||||
<div
|
||||
v-if="!isChecked(index) && !isEditForm"
|
||||
class="m-0 p-0"
|
||||
>
|
||||
<v-card-text class="markdown">
|
||||
<v-row>
|
||||
<v-col
|
||||
|
@ -254,7 +321,7 @@
|
|||
<div class="ml-n4">
|
||||
<RecipeIngredients
|
||||
:value="recipe.recipeIngredient.filter((ing) => {
|
||||
if(!step.ingredientReferences) return false
|
||||
if (!step.ingredientReferences) return false
|
||||
return step.ingredientReferences.map((ref) => ref.referenceId).includes(ing.referenceId || '')
|
||||
})"
|
||||
:scale="scale"
|
||||
|
@ -263,9 +330,15 @@
|
|||
/>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-divider v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.breakpoint.smAndUp" vertical ></v-divider>
|
||||
<v-divider
|
||||
v-if="isCookMode && step.ingredientReferences && step.ingredientReferences.length > 0 && $vuetify.display.smAndUp"
|
||||
vertical
|
||||
/>
|
||||
<v-col>
|
||||
<SafeMarkdown class="markdown" :source="step.text" />
|
||||
<SafeMarkdown
|
||||
class="markdown"
|
||||
:source="step.text"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
@ -275,34 +348,27 @@
|
|||
</v-hover>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</draggable>
|
||||
<v-divider v-if="!isCookMode" class="mt-10 d-flex d-md-none"/>
|
||||
</VueDraggable>
|
||||
<v-divider
|
||||
v-if="!isCookMode"
|
||||
class="mt-10 d-flex d-md-none"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import draggable from "vuedraggable";
|
||||
import {
|
||||
ref,
|
||||
toRefs,
|
||||
reactive,
|
||||
defineComponent,
|
||||
watch,
|
||||
onMounted,
|
||||
useContext,
|
||||
computed,
|
||||
nextTick,
|
||||
} from "@nuxtjs/composition-api";
|
||||
import { VueDraggable } from "vue-draggable-plus";
|
||||
import RecipeIngredientHtml from "../../RecipeIngredientHtml.vue";
|
||||
import { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
|
||||
import type { RecipeStep, IngredientReferences, RecipeIngredient, RecipeAsset, Recipe } from "~/lib/api/types/recipe";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import { uuid4, detectServerBaseUrl } from "~/composables/use-utils";
|
||||
import { uuid4 } from "~/composables/use-utils";
|
||||
import { useUserApi, useStaticRoutes } from "~/composables/api";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useExtractIngredientReferences } from "~/composables/recipe-page/use-extract-ingredient-references";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import DropZone from "~/components/global/DropZone.vue";
|
||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
||||
|
||||
interface MergerHistory {
|
||||
target: number;
|
||||
source: number;
|
||||
|
@ -310,15 +376,15 @@ interface MergerHistory {
|
|||
sourceText: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
draggable,
|
||||
VueDraggable,
|
||||
RecipeIngredientHtml,
|
||||
DropZone,
|
||||
RecipeIngredients
|
||||
RecipeIngredients,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Array as () => RecipeStep[],
|
||||
required: false,
|
||||
default: () => [],
|
||||
|
@ -336,10 +402,11 @@ export default defineComponent({
|
|||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "click-instruction-field", "update:assets"],
|
||||
|
||||
setup(props, context) {
|
||||
const { i18n, req } = useContext();
|
||||
const BASE_URL = detectServerBaseUrl(req);
|
||||
const i18n = useI18n();
|
||||
const BASE_URL = useRequestURL().origin;
|
||||
|
||||
const { isCookMode, toggleCookMode, isEditForm } = usePageState(props.recipe.slug);
|
||||
|
||||
|
@ -374,12 +441,12 @@ export default defineComponent({
|
|||
return !(title === null || title === "" || title === undefined);
|
||||
}
|
||||
|
||||
watch(props.value, (v) => {
|
||||
watch(props.modelValue, (v) => {
|
||||
state.disabledSteps = [];
|
||||
|
||||
v.forEach((element: RecipeStep) => {
|
||||
if (element.id !== undefined) {
|
||||
showTitleEditor.value[element.id] = hasSectionTitle(element.title);
|
||||
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -388,9 +455,9 @@ export default defineComponent({
|
|||
|
||||
// Eliminate state with an eager call to watcher?
|
||||
onMounted(() => {
|
||||
props.value.forEach((element: RecipeStep) => {
|
||||
props.modelValue.forEach((element: RecipeStep) => {
|
||||
if (element.id !== undefined) {
|
||||
showTitleEditor.value[element.id] = hasSectionTitle(element.title);
|
||||
showTitleEditor.value[element.id!] = hasSectionTitle(element.title!);
|
||||
}
|
||||
|
||||
// showCookMode.value = false;
|
||||
|
@ -411,7 +478,8 @@ export default defineComponent({
|
|||
if (index !== -1) {
|
||||
state.disabledSteps.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
state.disabledSteps.push(stepIndex);
|
||||
}
|
||||
}
|
||||
|
@ -433,8 +501,19 @@ export default defineComponent({
|
|||
showTitleEditor.value = temp;
|
||||
}
|
||||
|
||||
function updateIndex(data: RecipeStep) {
|
||||
context.emit("input", data);
|
||||
const instructionList = ref<RecipeStep[]>([...props.modelValue]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
instructionList.value = [...newVal];
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function onDragEnd() {
|
||||
context.emit("update:modelValue", [...instructionList.value]);
|
||||
drag.value = false;
|
||||
}
|
||||
|
||||
// ===============================================================
|
||||
|
@ -445,21 +524,21 @@ export default defineComponent({
|
|||
|
||||
function openDialog(idx: number, text: string, refs?: IngredientReferences[]) {
|
||||
if (!refs) {
|
||||
props.value[idx].ingredientReferences = [];
|
||||
refs = props.value[idx].ingredientReferences as IngredientReferences[];
|
||||
instructionList.value[idx].ingredientReferences = [];
|
||||
refs = props.modelValue[idx].ingredientReferences as IngredientReferences[];
|
||||
}
|
||||
|
||||
setUsedIngredients();
|
||||
activeText.value = text;
|
||||
activeIndex.value = idx;
|
||||
state.dialog = true;
|
||||
activeRefs.value = refs.map((ref) => ref.referenceId ?? "");
|
||||
activeRefs.value = refs.map(ref => ref.referenceId ?? "");
|
||||
}
|
||||
|
||||
const availableNextStep = computed(() => activeIndex.value < props.value.length - 1);
|
||||
const availableNextStep = computed(() => activeIndex.value < props.modelValue.length - 1);
|
||||
|
||||
function setIngredientIds() {
|
||||
const instruction = props.value[activeIndex.value];
|
||||
const instruction = props.modelValue[activeIndex.value];
|
||||
instruction.ingredientReferences = activeRefs.value.map((ref) => {
|
||||
return {
|
||||
referenceId: ref,
|
||||
|
@ -468,7 +547,7 @@ export default defineComponent({
|
|||
|
||||
// Update the visibility of the cook mode button
|
||||
showCookMode.value = false;
|
||||
props.value.forEach((element) => {
|
||||
props.modelValue.forEach((element) => {
|
||||
if (showCookMode.value === false && element.ingredientReferences && element.ingredientReferences.length > 0) {
|
||||
showCookMode.value = true;
|
||||
}
|
||||
|
@ -479,24 +558,23 @@ export default defineComponent({
|
|||
function saveAndOpenNextLinkIngredients() {
|
||||
const currentStepIndex = activeIndex.value;
|
||||
|
||||
if(!availableNextStep.value) {
|
||||
if (!availableNextStep.value) {
|
||||
return; // no next step, the button calling this function should not be shown
|
||||
}
|
||||
|
||||
setIngredientIds();
|
||||
const nextStep = props.value[currentStepIndex + 1];
|
||||
const nextStep = props.modelValue[currentStepIndex + 1];
|
||||
// close dialog before opening to reset the scroll position
|
||||
nextTick(() => openDialog(currentStepIndex + 1, nextStep.text, nextStep.ingredientReferences));
|
||||
|
||||
}
|
||||
|
||||
function setUsedIngredients() {
|
||||
const usedRefs: { [key: string]: boolean } = {};
|
||||
|
||||
props.value.forEach((element) => {
|
||||
props.modelValue.forEach((element) => {
|
||||
element.ingredientReferences?.forEach((ref) => {
|
||||
if (ref.referenceId !== undefined) {
|
||||
usedRefs[ref.referenceId] = true;
|
||||
usedRefs[ref.referenceId!] = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -515,7 +593,7 @@ export default defineComponent({
|
|||
props.recipe.recipeIngredient,
|
||||
activeRefs.value,
|
||||
activeText.value,
|
||||
props.recipe.settings.disableAmount
|
||||
props.recipe.settings.disableAmount,
|
||||
).forEach((ingredient: string) => activeRefs.value.push(ingredient));
|
||||
}
|
||||
|
||||
|
@ -535,10 +613,8 @@ export default defineComponent({
|
|||
return "";
|
||||
}
|
||||
|
||||
const ing = ingredientLookup.value[refId] ?? "";
|
||||
if (ing === "") {
|
||||
return "";
|
||||
}
|
||||
const ing = ingredientLookup.value[refId];
|
||||
if (!ing) return "";
|
||||
return parseIngredientText(ing, props.recipe.settings.disableAmount, props.scale);
|
||||
}
|
||||
|
||||
|
@ -554,12 +630,12 @@ export default defineComponent({
|
|||
mergeHistory.value.push({
|
||||
target,
|
||||
source,
|
||||
targetText: props.value[target].text,
|
||||
sourceText: props.value[source].text,
|
||||
targetText: props.modelValue[target].text,
|
||||
sourceText: props.modelValue[source].text,
|
||||
});
|
||||
|
||||
props.value[target].text += " " + props.value[source].text;
|
||||
props.value.splice(source, 1);
|
||||
instructionList.value[target].text += " " + props.modelValue[source].text;
|
||||
instructionList.value.splice(source, 1);
|
||||
}
|
||||
|
||||
function undoMerge(event: KeyboardEvent) {
|
||||
|
@ -573,8 +649,8 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
props.value[lastMerge.target].text = lastMerge.targetText;
|
||||
props.value.splice(lastMerge.source, 0, {
|
||||
instructionList.value[lastMerge.target].text = lastMerge.targetText;
|
||||
instructionList.value.splice(lastMerge.source, 0, {
|
||||
id: uuid4(),
|
||||
title: "",
|
||||
text: lastMerge.sourceText,
|
||||
|
@ -585,14 +661,15 @@ export default defineComponent({
|
|||
|
||||
function moveTo(dest: string, source: number) {
|
||||
if (dest === "top") {
|
||||
props.value.unshift(props.value.splice(source, 1)[0]);
|
||||
} else {
|
||||
props.value.push(props.value.splice(source, 1)[0]);
|
||||
instructionList.value.unshift(instructionList.value.splice(source, 1)[0]);
|
||||
}
|
||||
else {
|
||||
instructionList.value.push(instructionList.value.splice(source, 1)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function insert(dest: number) {
|
||||
props.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
|
||||
instructionList.value.splice(dest, 0, { id: uuid4(), text: "", title: "", ingredientReferences: [] });
|
||||
}
|
||||
|
||||
const previewStates = ref<boolean[]>([]);
|
||||
|
@ -606,19 +683,21 @@ export default defineComponent({
|
|||
function toggleCollapseSection(index: number) {
|
||||
const sectionSteps: number[] = [];
|
||||
|
||||
for (let i = index; i < props.value.length; i++) {
|
||||
if (!(i === index) && hasSectionTitle(props.value[i].title)) {
|
||||
for (let i = index; i < instructionList.value.length; i++) {
|
||||
if (!(i === index) && hasSectionTitle(instructionList.value[i].title!)) {
|
||||
break;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
sectionSteps.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const allCollapsed = sectionSteps.every((idx) => state.disabledSteps.includes(idx));
|
||||
const allCollapsed = sectionSteps.every(idx => state.disabledSteps.includes(idx));
|
||||
|
||||
if (allCollapsed) {
|
||||
state.disabledSteps = state.disabledSteps.filter((idx) => !sectionSteps.includes(idx));
|
||||
} else {
|
||||
state.disabledSteps = state.disabledSteps.filter(idx => !sectionSteps.includes(idx));
|
||||
}
|
||||
else {
|
||||
state.disabledSteps = [...state.disabledSteps, ...sectionSteps];
|
||||
}
|
||||
}
|
||||
|
@ -674,7 +753,7 @@ export default defineComponent({
|
|||
context.emit("update:assets", [...props.assets, data]);
|
||||
const assetUrl = BASE_URL + recipeAssetPath(props.recipe.id, data.fileName as string);
|
||||
const text = `<img src="${assetUrl}" height="100%" width="100%"/>`;
|
||||
props.value[index].text += text;
|
||||
instructionList.value[index].text += text;
|
||||
}
|
||||
|
||||
function openImageUpload(index: number) {
|
||||
|
@ -690,6 +769,8 @@ export default defineComponent({
|
|||
input.click();
|
||||
}
|
||||
|
||||
const breakpoint = useDisplay();
|
||||
|
||||
return {
|
||||
// Image Uploader
|
||||
toggleDragMode,
|
||||
|
@ -699,6 +780,7 @@ export default defineComponent({
|
|||
loadingStates,
|
||||
|
||||
// Rest
|
||||
onDragEnd,
|
||||
drag,
|
||||
togglePreviewState,
|
||||
toggleCollapseSection,
|
||||
|
@ -719,7 +801,7 @@ export default defineComponent({
|
|||
toggleDisabled,
|
||||
isChecked,
|
||||
toggleShowTitle,
|
||||
updateIndex,
|
||||
instructionList,
|
||||
autoSetReferences,
|
||||
parseIngredientText,
|
||||
toggleCookMode,
|
||||
|
@ -727,6 +809,7 @@ export default defineComponent({
|
|||
isCookMode,
|
||||
isEditForm,
|
||||
insert,
|
||||
breakpoint,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
@ -738,28 +821,32 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
/** Select all li under .markdown class */
|
||||
.markdown >>> ul > li {
|
||||
.markdown :deep(ul > li) {
|
||||
display: list-item;
|
||||
list-style-type: disc !important;
|
||||
}
|
||||
|
||||
/** Select all li under .markdown class */
|
||||
.markdown >>> ol > li {
|
||||
.markdown :deep(ol > li) {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.flip-list-move {
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.no-move {
|
||||
transition: transform 0s;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.list-group {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.list-group-item i {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
@ -780,4 +867,8 @@ export default defineComponent({
|
|||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.v-text-field >>> input {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Recipe Categories -->
|
||||
<v-card v-if="recipe.recipeCategory.length > 0 || isEditForm" :class="{'mt-10': !isEditForm}">
|
||||
<v-card
|
||||
v-if="recipe.recipeCategory.length > 0 || isEditForm"
|
||||
:class="{ 'mt-10': !isEditForm }"
|
||||
>
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("recipe.categories") }}
|
||||
</v-card-title>
|
||||
|
@ -14,12 +17,19 @@
|
|||
:show-add="true"
|
||||
selector-type="categories"
|
||||
/>
|
||||
<RecipeChips v-else :items="recipe.recipeCategory" v-on="$listeners" />
|
||||
<RecipeChips
|
||||
v-else
|
||||
:items="recipe.recipeCategory"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Recipe Tags -->
|
||||
<v-card v-if="recipe.tags.length > 0 || isEditForm" class="mt-4">
|
||||
<v-card
|
||||
v-if="recipe.tags.length > 0 || isEditForm"
|
||||
class="mt-4"
|
||||
>
|
||||
<v-card-title class="py-2">
|
||||
{{ $t("tag.tags") }}
|
||||
</v-card-title>
|
||||
|
@ -32,20 +42,39 @@
|
|||
:show-add="true"
|
||||
selector-type="tags"
|
||||
/>
|
||||
<RecipeChips v-else :items="recipe.tags" url-prefix="tags" v-on="$listeners" />
|
||||
<RecipeChips
|
||||
v-else
|
||||
:items="recipe.tags"
|
||||
url-prefix="tags"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Recipe Tools Edit -->
|
||||
<v-card v-if="isEditForm" class="mt-2">
|
||||
<v-card-title class="py-2"> {{ $t('tool.required-tools') }} </v-card-title>
|
||||
<v-card
|
||||
v-if="isEditForm"
|
||||
class="mt-2"
|
||||
>
|
||||
<v-card-title class="py-2">
|
||||
{{ $t('tool.required-tools') }}
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2" />
|
||||
<v-card-text class="pt-0">
|
||||
<RecipeOrganizerSelector v-model="recipe.tools" selector-type="tools" v-on="$listeners" />
|
||||
<RecipeOrganizerSelector
|
||||
v-model="recipe.tools"
|
||||
selector-type="tools"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<RecipeNutrition v-if="recipe.settings.showNutrition" v-model="recipe.nutrition" class="mt-4" :edit="isEditForm" />
|
||||
<RecipeNutrition
|
||||
v-if="recipe.settings.showNutrition"
|
||||
v-model="recipe.nutrition"
|
||||
class="mt-4"
|
||||
:edit="isEditForm"
|
||||
/>
|
||||
<RecipeAssets
|
||||
v-if="recipe.settings.showAssets"
|
||||
v-model="recipe.assets"
|
||||
|
@ -56,38 +85,15 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
<script lang="ts" setup>
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import RecipeOrganizerSelector from "@/components/Domain/Recipe/RecipeOrganizerSelector.vue";
|
||||
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
|
||||
import RecipeChips from "@/components/Domain/Recipe/RecipeChips.vue";
|
||||
import RecipeAssets from "@/components/Domain/Recipe/RecipeAssets.vue";
|
||||
export default defineComponent({
|
||||
components: {
|
||||
RecipeOrganizerSelector,
|
||||
RecipeNutrition,
|
||||
RecipeChips,
|
||||
RecipeAssets,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { user } = usePageUser();
|
||||
const { isEditForm } = usePageState(props.recipe.slug);
|
||||
|
||||
|
||||
|
||||
return {
|
||||
isEditForm,
|
||||
user,
|
||||
};
|
||||
},
|
||||
});
|
||||
const recipe = defineModel<NoUndefinedField<Recipe>>({ required: true });
|
||||
const { isEditForm } = usePageState(recipe.value.slug);
|
||||
</script>
|
||||
|
|
|
@ -1,28 +1,21 @@
|
|||
<template>
|
||||
<div class="d-flex justify-space-between align-center pt-2 pb-3">
|
||||
<v-tooltip v-if="!isEditMode" small top color="secondary darken-1">
|
||||
<template #activator="{ on, attrs }">
|
||||
<RecipeScaleEditButton
|
||||
v-model.number="scaleValue"
|
||||
v-bind="attrs"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
v-on="on"
|
||||
/>
|
||||
</template>
|
||||
<span> {{ $t("recipe.edit-scale") }} </span>
|
||||
</v-tooltip>
|
||||
<RecipeScaleEditButton
|
||||
v-if="!isEditMode"
|
||||
v-model.number="scaleValue"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent } from "@nuxtjs/composition-api";
|
||||
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeScaleEditButton,
|
||||
},
|
||||
|
@ -36,6 +29,7 @@ export default defineComponent({
|
|||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["update:scale"],
|
||||
setup(props, { emit }) {
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
import RecipePage from "./RecipePage.vue";
|
||||
|
||||
export default RecipePage;
|
|
@ -1,15 +1,18 @@
|
|||
<template>
|
||||
<div class="print-container">
|
||||
<RecipePrintView :recipe="recipe" :scale="scale" dense />
|
||||
</div>
|
||||
<div class="print-container">
|
||||
<RecipePrintView
|
||||
:recipe="recipe"
|
||||
:scale="scale"
|
||||
:density="'compact'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||
import { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePrintView,
|
||||
},
|
||||
|
|
|
@ -1,47 +1,54 @@
|
|||
<!-- eslint-disable vue/no-v-html -->
|
||||
<template>
|
||||
<div :class="dense ? 'wrapper' : 'wrapper pa-3'">
|
||||
<section>
|
||||
<v-container class="ma-0 pa-0">
|
||||
<v-row>
|
||||
<v-col
|
||||
v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||
cols="4"
|
||||
align-self="center"
|
||||
<v-col v-if="preferences.imagePosition && preferences.imagePosition != ImagePosition.hidden"
|
||||
:order="preferences.imagePosition == ImagePosition.left ? -1 : 1"
|
||||
cols="4"
|
||||
align-self="center"
|
||||
>
|
||||
<img :key="imageKey" :src="recipeImageUrl" style="min-height: 50; max-width: 100%;" />
|
||||
<img :key="imageKey"
|
||||
:src="recipeImageUrl"
|
||||
style="min-height: 50; max-width: 100%;"
|
||||
>
|
||||
</v-col>
|
||||
<v-col order=0>
|
||||
<v-col order="0">
|
||||
<v-card-title class="headline pl-0">
|
||||
<v-icon left color="primary">
|
||||
<v-icon start
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.primary }}
|
||||
</v-icon>
|
||||
{{ recipe.name }}
|
||||
</v-card-title>
|
||||
<div v-if="recipeYield" class="d-flex justify-space-between align-center px-4 pb-2">
|
||||
<v-chip
|
||||
:small="$vuetify.breakpoint.smAndDown"
|
||||
label
|
||||
<div v-if="recipeYield"
|
||||
class="d-flex justify-space-between align-center px-4 pb-2"
|
||||
>
|
||||
<v-chip :size="$vuetify.display.smAndDown ? 'small' : undefined"
|
||||
label
|
||||
>
|
||||
<v-icon left>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="recipeYield"></span>
|
||||
<span v-html="recipeYield" />
|
||||
</v-chip>
|
||||
</div>
|
||||
<v-row class="d-flex justify-start">
|
||||
<RecipeTimeCard
|
||||
:prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
small
|
||||
color="white"
|
||||
class="ml-4"
|
||||
<RecipeTimeCard :prep-time="recipe.prepTime"
|
||||
:total-time="recipe.totalTime"
|
||||
:perform-time="recipe.performTime"
|
||||
small
|
||||
color="white"
|
||||
class="ml-4"
|
||||
/>
|
||||
</v-row>
|
||||
|
||||
<v-card-text v-if="preferences.showDescription" class="px-0">
|
||||
<v-card-text v-if="preferences.showDescription"
|
||||
class="px-0"
|
||||
>
|
||||
<SafeMarkdown :source="recipe.description" />
|
||||
</v-card-text>
|
||||
</v-col>
|
||||
|
@ -51,22 +58,28 @@
|
|||
|
||||
<!-- Ingredients -->
|
||||
<section>
|
||||
<v-card-title class="headline pl-0"> {{ $t("recipe.ingredients") }} </v-card-title>
|
||||
<div
|
||||
v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
class="print-section"
|
||||
<v-card-title class="headline pl-0">
|
||||
{{ $t("recipe.ingredients") }}
|
||||
</v-card-title>
|
||||
<div v-for="(ingredientSection, sectionIndex) in ingredientSections"
|
||||
:key="`ingredient-section-${sectionIndex}`"
|
||||
class="print-section"
|
||||
>
|
||||
<h4 v-if="ingredientSection.ingredients[0].title" class="ingredient-title mt-2">
|
||||
<h4 v-if="ingredientSection.ingredients[0].title"
|
||||
class="ingredient-title mt-2"
|
||||
>
|
||||
{{ ingredientSection.ingredients[0].title }}
|
||||
</h4>
|
||||
<div
|
||||
class="ingredient-grid"
|
||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
<div class="ingredient-grid"
|
||||
:style="{ gridTemplateRows: `repeat(${Math.ceil(ingredientSection.ingredients.length / 2)}, min-content)` }"
|
||||
>
|
||||
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients">
|
||||
<template v-for="(ingredient, ingredientIndex) in ingredientSection.ingredients"
|
||||
:key="`ingredient-${ingredientIndex}`"
|
||||
>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<p :key="`ingredient-${ingredientIndex}`" class="ingredient-body" v-html="parseText(ingredient)" />
|
||||
<p class="ingredient-body"
|
||||
v-html="parseText(ingredient)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -74,19 +87,35 @@
|
|||
|
||||
<!-- Instructions -->
|
||||
<section>
|
||||
<div
|
||||
v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
<div v-for="(instructionSection, sectionIndex) in instructionSections"
|
||||
:key="`instruction-section-${sectionIndex}`"
|
||||
:class="{ 'print-section': instructionSection.sectionName }"
|
||||
>
|
||||
<v-card-title v-if="!sectionIndex" class="headline pl-0">{{ $t("recipe.instructions") }}</v-card-title>
|
||||
<div v-for="(step, stepIndex) in instructionSection.instructions" :key="`instruction-${stepIndex}`">
|
||||
<v-card-title v-if="!sectionIndex"
|
||||
class="headline pl-0"
|
||||
>
|
||||
{{ $t("recipe.instructions") }}
|
||||
</v-card-title>
|
||||
<div v-for="(step, stepIndex) in instructionSection.instructions"
|
||||
:key="`instruction-${stepIndex}`"
|
||||
>
|
||||
<div class="print-section">
|
||||
<h4 v-if="step.title" :key="`instruction-title-${stepIndex}`" class="instruction-title mb-2">
|
||||
<h4 v-if="step.title"
|
||||
:key="`instruction-title-${stepIndex}`"
|
||||
class="instruction-title mb-2"
|
||||
>
|
||||
{{ step.title }}
|
||||
</h4>
|
||||
<h5>{{ step.summary ? step.summary : $t("recipe.step-index", { step: stepIndex + instructionSection.stepOffset + 1 }) }}</h5>
|
||||
<SafeMarkdown :source="step.text" class="recipe-step-body" />
|
||||
<h5>
|
||||
{{ step.summary ? step.summary : $t("recipe.step-index", {
|
||||
step: stepIndex
|
||||
+ instructionSection.stepOffset
|
||||
+ 1,
|
||||
}) }}
|
||||
</h5>
|
||||
<SafeMarkdown :source="step.text"
|
||||
class="recipe-step-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -94,13 +123,19 @@
|
|||
|
||||
<!-- Notes -->
|
||||
<div v-if="preferences.showNotes">
|
||||
<v-divider v-if="hasNotes" class="grey my-4"></v-divider>
|
||||
<v-divider v-if="hasNotes"
|
||||
class="grey my-4"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<div v-for="(note, index) in recipe.notes" :key="index + 'note'">
|
||||
<div v-for="(note, index) in recipe.notes"
|
||||
:key="index + 'note'"
|
||||
>
|
||||
<div class="print-section">
|
||||
<h4>{{ note.title }}</h4>
|
||||
<SafeMarkdown :source="note.text" class="note-body" />
|
||||
<SafeMarkdown :source="note.text"
|
||||
class="note-body"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
@ -108,13 +143,17 @@
|
|||
|
||||
<!-- Nutrition -->
|
||||
<div v-if="preferences.showNutrition">
|
||||
<v-card-title class="headline pl-0"> {{ $t("recipe.nutrition") }} </v-card-title>
|
||||
<v-card-title class="headline pl-0">
|
||||
{{ $t("recipe.nutrition") }}
|
||||
</v-card-title>
|
||||
|
||||
<section>
|
||||
<div class="print-section">
|
||||
<table class="nutrition-table">
|
||||
<tbody>
|
||||
<tr v-for="(value, key) in recipe.nutrition" :key="key">
|
||||
<tr v-for="(value, key) in recipe.nutrition"
|
||||
:key="key"
|
||||
>
|
||||
<template v-if="value">
|
||||
<td>{{ labels[key].label }}</td>
|
||||
<td>{{ value ? (labels[key].suffix ? `${value} ${labels[key].suffix}` : value) : '-' }}</td>
|
||||
|
@ -122,26 +161,23 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { Recipe, RecipeIngredient, RecipeStep} from "~/lib/api/types/recipe";
|
||||
import { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import type { Recipe, RecipeIngredient, RecipeStep } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
|
||||
import { parseIngredientText, useNutritionLabels } from "~/composables/recipes";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
|
||||
type IngredientSection = {
|
||||
sectionName: string;
|
||||
ingredients: RecipeIngredient[];
|
||||
|
@ -153,7 +189,7 @@ type InstructionSection = {
|
|||
instructions: RecipeStep[];
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
},
|
||||
|
@ -168,15 +204,15 @@ export default defineComponent({
|
|||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const preferences = useUserPrintPreferences();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const {labels} = useNutritionLabels();
|
||||
const { labels } = useNutritionLabels();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
|
@ -187,11 +223,13 @@ export default defineComponent({
|
|||
|
||||
const servingsDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string : "";
|
||||
})
|
||||
return scaledAmountDisplay
|
||||
? i18n.t("recipe.yields-amount-with-text", {
|
||||
amount: scaledAmountDisplay,
|
||||
text: props.recipe.recipeYield,
|
||||
}) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
const yieldDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeServings, props.scale);
|
||||
|
@ -201,10 +239,11 @@ export default defineComponent({
|
|||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
|
@ -320,7 +359,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
.wrapper,
|
||||
.wrapper >>> * {
|
||||
.wrapper :deep(*) {
|
||||
opacity: 1 !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
@ -396,10 +435,10 @@ li {
|
|||
width: 30%;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.nutrition-table td {
|
||||
padding: 2px;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -1,31 +1,30 @@
|
|||
<template>
|
||||
<div @click.prevent>
|
||||
<!-- User Rating -->
|
||||
<v-hover v-slot="{ hover }">
|
||||
<v-rating
|
||||
v-if="isOwnGroup && (userRating || hover || !ratingsLoaded)"
|
||||
:value="userRating"
|
||||
color="secondary"
|
||||
background-color="secondary lighten-3"
|
||||
<v-hover v-slot="{ isHovering, props }">
|
||||
<v-rating v-if="isOwnGroup && (userRating || isHovering || !ratingsLoaded)"
|
||||
v-bind="props"
|
||||
:model-value="userRating"
|
||||
active-color="secondary"
|
||||
color="secondary-lighten-3"
|
||||
length="5"
|
||||
:dense="small ? true : undefined"
|
||||
:size="small ? 15 : undefined"
|
||||
:density="small ? 'compact' : 'default'"
|
||||
:size="small ? 'x-small' : undefined"
|
||||
hover
|
||||
clearable
|
||||
@input="updateRating"
|
||||
@update:model-value="updateRating(+$event)"
|
||||
@click="updateRating"
|
||||
/>
|
||||
<!-- Group Rating -->
|
||||
<v-rating
|
||||
v-else
|
||||
:value="groupRating"
|
||||
<v-rating v-else
|
||||
v-bind="props"
|
||||
:model-value="groupRating"
|
||||
:half-increments="true"
|
||||
:readonly="true"
|
||||
color="grey darken-1"
|
||||
background-color="secondary lighten-3"
|
||||
active-color="grey-darken-1"
|
||||
color="secondary-lighten-3"
|
||||
length="5"
|
||||
:dense="small ? true : undefined"
|
||||
:size="small ? 15 : undefined"
|
||||
:density="small ? 'compact' : 'default'"
|
||||
:size="small ? 'x-small' : undefined"
|
||||
hover
|
||||
/>
|
||||
</v-hover>
|
||||
|
@ -33,10 +32,10 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, watch } from "@nuxtjs/composition-api";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
emitOnly: {
|
||||
type: Boolean,
|
||||
|
@ -50,7 +49,7 @@ export default defineComponent({
|
|||
type: String,
|
||||
default: "",
|
||||
},
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
|
@ -59,12 +58,13 @@ export default defineComponent({
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const { userRatings, setRating, ready: ratingsLoaded } = useUserSelfRatings();
|
||||
|
||||
const userRating = computed(() => {
|
||||
return userRatings.value.find((r) => r.recipeId === props.recipeId)?.rating;
|
||||
return userRatings.value.find(r => r.recipeId === props.recipeId)?.rating ?? undefined;
|
||||
});
|
||||
|
||||
// if a user unsets their rating, we don't want to fall back to the group rating since it's out of sync
|
||||
|
@ -76,13 +76,13 @@ export default defineComponent({
|
|||
hideGroupRating.value = true;
|
||||
}
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
const groupRating = computed(() => {
|
||||
return hideGroupRating.value ? 0 : props.value;
|
||||
return hideGroupRating.value ? 0 : props.modelValue;
|
||||
});
|
||||
|
||||
function updateRating(val: number | null) {
|
||||
function updateRating(val?: number) {
|
||||
if (!isOwnGroup.value) {
|
||||
return;
|
||||
}
|
||||
|
@ -90,7 +90,7 @@ export default defineComponent({
|
|||
if (!props.emitOnly) {
|
||||
setRating(props.slug, val || 0, null);
|
||||
}
|
||||
context.emit("input", val);
|
||||
context.emit("update:modelValue", val);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
@ -2,20 +2,61 @@
|
|||
<div v-if="yieldDisplay">
|
||||
<div class="text-center d-flex align-center">
|
||||
<div>
|
||||
<v-menu v-model="menu" :disabled="!canEditScale" offset-y top nudge-top="6" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-menu
|
||||
v-model="menu"
|
||||
:disabled="!canEditScale"
|
||||
offset-y
|
||||
top
|
||||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-tooltip
|
||||
v-if="canEditScale"
|
||||
size="small"
|
||||
top
|
||||
color="secondary-darken-1"
|
||||
>
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-card
|
||||
class="pa-1 px-2"
|
||||
dark
|
||||
color="secondary-darken-1"
|
||||
size="small"
|
||||
v-bind="{ ...props, ...tooltipProps }"
|
||||
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||
>
|
||||
<v-icon
|
||||
v-if="canEditScale"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="yieldDisplay" />
|
||||
</v-card>
|
||||
</template>
|
||||
<span> {{ $t("recipe.edit-scale") }} </span>
|
||||
</v-tooltip>
|
||||
<v-card
|
||||
v-else
|
||||
class="pa-1 px-2"
|
||||
dark
|
||||
color="secondary darken-1"
|
||||
small
|
||||
v-bind="attrs"
|
||||
color="secondary-darken-1"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||
v-on="on"
|
||||
>
|
||||
<v-icon v-if="canEditScale" small class="mr-2">{{ $globals.icons.edit }}</v-icon>
|
||||
<v-icon
|
||||
v-if="canEditScale"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ $globals.icons.edit }}
|
||||
</v-icon>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="yieldDisplay"></span>
|
||||
<span v-html="yieldDisplay" />
|
||||
</v-card>
|
||||
</template>
|
||||
<v-card min-width="300px">
|
||||
|
@ -24,10 +65,26 @@
|
|||
</v-card-title>
|
||||
<v-card-text class="mt-n5">
|
||||
<div class="mt-4 d-flex align-center">
|
||||
<v-text-field v-model="yieldQuantityEditorValue" type="number" :min="0" hide-spin-buttons @input="recalculateScale(yieldQuantityEditorValue)" />
|
||||
<v-tooltip right color="secondary darken-1">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn v-bind="attrs" icon class="mx-1" small v-on="on" @click="scale = 1">
|
||||
<v-text-field
|
||||
:model-value="yieldQuantityEditorValue"
|
||||
type="number"
|
||||
:min="0"
|
||||
variant="underlined"
|
||||
hide-spin-buttons
|
||||
@update:model-value="recalculateScale(yieldQuantityEditorValue)"
|
||||
/>
|
||||
<v-tooltip
|
||||
end
|
||||
color="secondary-darken-1"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
icon
|
||||
class="mx-1"
|
||||
size="small"
|
||||
@click="scale = 1"
|
||||
>
|
||||
<v-icon>
|
||||
{{ $globals.icons.undo }}
|
||||
</v-icon>
|
||||
|
@ -47,13 +104,13 @@
|
|||
:buttons="[
|
||||
{
|
||||
icon: $globals.icons.minus,
|
||||
text: $tc('recipe.decrease-scale-label'),
|
||||
text: $t('recipe.decrease-scale-label'),
|
||||
event: 'decrement',
|
||||
disabled: disableDecrement,
|
||||
},
|
||||
{
|
||||
icon: $globals.icons.createAlt,
|
||||
text: $tc('recipe.increase-scale-label'),
|
||||
text: $t('recipe.increase-scale-label'),
|
||||
event: 'increment',
|
||||
},
|
||||
]"
|
||||
|
@ -65,12 +122,11 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext, watch } from "@nuxtjs/composition-api";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
|
@ -83,16 +139,17 @@ export default defineComponent({
|
|||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const menu = ref<boolean>(false);
|
||||
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||
|
||||
const scale = computed({
|
||||
get: () => props.value,
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
const newScaleNumber = parseFloat(`${value}`);
|
||||
emit("input", isNaN(newScaleNumber) ? 0 : newScaleNumber);
|
||||
emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -103,7 +160,8 @@ export default defineComponent({
|
|||
|
||||
if (props.recipeServings <= 0) {
|
||||
scale.value = 1;
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
|
@ -113,9 +171,11 @@ export default defineComponent({
|
|||
});
|
||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||
const yieldDisplay = computed(() => {
|
||||
return yieldQuantity.value ? i18n.t(
|
||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay }
|
||||
) as string : "";
|
||||
return yieldQuantity.value
|
||||
? i18n.t(
|
||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
|
||||
) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
// only update yield quantity when the menu opens, so we don't override the user's input
|
||||
|
@ -128,8 +188,8 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
yieldQuantityEditorValue.value = recipeYieldAmount.value.scaledAmount;
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
const disableDecrement = computed(() => {
|
||||
return recipeYieldAmount.value.scaledAmount <= 1;
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
<template>
|
||||
<div class="d-flex justify-center align-center">
|
||||
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory @change="emitMulti">
|
||||
<v-btn small :value="false">
|
||||
<v-btn-toggle v-model="selected" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
|
||||
<v-btn size="small" :value="false">
|
||||
{{ $t("search.include") }}
|
||||
</v-btn>
|
||||
<v-btn small :value="true">
|
||||
<v-btn size="small" :value="true">
|
||||
{{ $t("search.exclude") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory @change="emitMulti">
|
||||
<v-btn small :value="false" class="text-uppercase">
|
||||
<v-btn-toggle v-model="match" tile group color="primary accent-3" mandatory="force" @change="emitMulti">
|
||||
<v-btn size="small" :value="false" class="text-uppercase">
|
||||
{{ $t("search.and") }}
|
||||
</v-btn>
|
||||
<v-btn small :value="true" class="text-uppercase">
|
||||
<v-btn size="small" :value="true" class="text-uppercase">
|
||||
{{ $t("search.or") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
|
@ -20,17 +20,16 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
|
||||
type SelectionValue = "include" | "exclude" | "any";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: String as () => SelectionValue,
|
||||
default: "include",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "update"],
|
||||
data() {
|
||||
return {
|
||||
selected: false,
|
||||
|
@ -39,7 +38,7 @@ export default defineComponent({
|
|||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
this.$emit("input", this.selected);
|
||||
this.$emit("update:modelValue", this.selected);
|
||||
},
|
||||
emitMulti() {
|
||||
const updateData = {
|
||||
|
|
|
@ -1,9 +1,18 @@
|
|||
<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>
|
||||
<v-menu
|
||||
offset-y
|
||||
top
|
||||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.cog }}
|
||||
</v-icon>
|
||||
{{ $t("general.settings") }}
|
||||
|
@ -15,32 +24,24 @@
|
|||
{{ $t("recipe.recipe-settings") }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-divider class="mx-2"></v-divider>
|
||||
<v-divider class="mx-2" />
|
||||
<v-card-text class="mt-n5 pt-6 pb-2">
|
||||
<RecipeSettingsSwitches v-model="value" :is-owner="isOwner" />
|
||||
<RecipeSettingsSwitches
|
||||
v-model="value"
|
||||
:is-owner="isOwner"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
<script lang="ts" setup>
|
||||
import RecipeSettingsSwitches from "./RecipeSettingsSwitches.vue";
|
||||
|
||||
export default defineComponent({
|
||||
components: { RecipeSettingsSwitches },
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
isOwner: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const value = defineModel<object>({ required: true });
|
||||
|
||||
defineProps<{ isOwner?: boolean }>();
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
|
|
@ -1,51 +1,39 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-switch
|
||||
v-for="(_, key) in value"
|
||||
v-for="(_, key) in model"
|
||||
:key="key"
|
||||
v-model="value[key]"
|
||||
v-model="model[key]"
|
||||
color="primary"
|
||||
xs
|
||||
dense
|
||||
density="compact"
|
||||
:disabled="key == 'locked' && !isOwner"
|
||||
class="my-1"
|
||||
:label="labels[key]"
|
||||
hide-details
|
||||
></v-switch>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
import { RecipeSettings } from "~/lib/api/types/recipe";
|
||||
<script lang="ts" setup>
|
||||
import { defineModel, defineProps } from "vue";
|
||||
import type { RecipeSettings } from "~/lib/api/types/recipe";
|
||||
import { useI18n } from "#imports";
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
value: {
|
||||
type: Object as () => RecipeSettings,
|
||||
required: true,
|
||||
},
|
||||
isOwner: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { i18n } = useContext();
|
||||
const labels: Record<keyof RecipeSettings, string> = {
|
||||
public: i18n.tc("recipe.public-recipe"),
|
||||
showNutrition: i18n.tc("recipe.show-nutrition-values"),
|
||||
showAssets: i18n.tc("asset.show-assets"),
|
||||
landscapeView: i18n.tc("recipe.landscape-view-coming-soon"),
|
||||
disableComments: i18n.tc("recipe.disable-comments"),
|
||||
disableAmount: i18n.tc("recipe.disable-amount"),
|
||||
locked: i18n.tc("recipe.locked"),
|
||||
};
|
||||
defineProps<{ isOwner?: boolean }>();
|
||||
|
||||
return {
|
||||
labels,
|
||||
};
|
||||
},
|
||||
});
|
||||
const model = defineModel<RecipeSettings>({ required: true });
|
||||
|
||||
const i18n = useI18n();
|
||||
const labels: Record<keyof RecipeSettings, string> = {
|
||||
public: i18n.t("recipe.public-recipe"),
|
||||
showNutrition: i18n.t("recipe.show-nutrition-values"),
|
||||
showAssets: i18n.t("asset.show-assets"),
|
||||
landscapeView: i18n.t("recipe.landscape-view-coming-soon"),
|
||||
disableComments: i18n.t("recipe.disable-comments"),
|
||||
disableAmount: i18n.t("recipe.disable-amount"),
|
||||
locked: i18n.t("recipe.locked"),
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
|
|
@ -12,23 +12,23 @@
|
|||
/>
|
||||
</v-col>
|
||||
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
|
||||
<v-col
|
||||
v-if="organizer.show"
|
||||
cols="12"
|
||||
>
|
||||
<v-col v-if="organizer.show" cols="12">
|
||||
<div class="d-flex flex-row flex-wrap align-center pt-2">
|
||||
<v-icon class="ma-0 pa-0">{{ organizer.icon }}</v-icon>
|
||||
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content;">
|
||||
{{ $tc("recipe-finder.missing") }}:
|
||||
<v-icon class="ma-0 pa-0">
|
||||
{{ organizer.icon }}
|
||||
</v-icon>
|
||||
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
|
||||
{{ $t("recipe-finder.missing") }}:
|
||||
</v-card-text>
|
||||
<v-chip
|
||||
v-for="item in organizer.items"
|
||||
:key="item.item.id"
|
||||
label
|
||||
color="secondary custom-transparent"
|
||||
class="mr-2 my-1"
|
||||
class="mr-2 my-1 pl-1"
|
||||
variant="flat"
|
||||
>
|
||||
<v-checkbox dark :ripple="false" @click="handleCheckbox(item)">
|
||||
<v-checkbox dark :ripple="false" hide-details @click="handleCheckbox(item)">
|
||||
<template #label>
|
||||
{{ organizer.getLabel(item.item) }}
|
||||
</template>
|
||||
|
@ -42,9 +42,8 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, reactive, useContext } from "@nuxtjs/composition-api";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
|
||||
import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
interface Organizer {
|
||||
type: "food" | "tool";
|
||||
|
@ -52,7 +51,7 @@ interface Organizer {
|
|||
selected: boolean;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardMobile },
|
||||
props: {
|
||||
recipe: {
|
||||
|
@ -73,27 +72,31 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $globals } = useContext();
|
||||
const { $globals } = useNuxtApp();
|
||||
const missingOrganizers = computed(() => [
|
||||
{
|
||||
type: "food",
|
||||
show: props.missingFoods?.length,
|
||||
icon: $globals.icons.foods,
|
||||
items: props.missingFoods ? props.missingFoods.map((food) => {
|
||||
return reactive({type: "food", item: food, selected: false} as Organizer);
|
||||
}) : [],
|
||||
items: props.missingFoods
|
||||
? props.missingFoods.map((food) => {
|
||||
return reactive({ type: "food", item: food, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: IngredientFood) => item.pluralName || item.name,
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
show: props.missingTools?.length,
|
||||
icon: $globals.icons.tools,
|
||||
items: props.missingTools ? props.missingTools.map((tool) => {
|
||||
return reactive({type: "tool", item: tool, selected: false} as Organizer);
|
||||
}) : [],
|
||||
items: props.missingTools
|
||||
? props.missingTools.map((tool) => {
|
||||
return reactive({ type: "tool", item: tool, selected: false } as Organizer);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: RecipeTool) => item.name,
|
||||
}
|
||||
])
|
||||
},
|
||||
]);
|
||||
|
||||
function handleCheckbox(organizer: Organizer) {
|
||||
if (props.disableCheckbox) {
|
||||
|
@ -113,6 +116,6 @@ export default defineComponent({
|
|||
missingOrganizers,
|
||||
handleCheckbox,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,34 +1,77 @@
|
|||
<template v-if="showCards">
|
||||
<div class="text-center">
|
||||
<!-- Total Time -->
|
||||
<div v-if="validateTotalTime" class="time-card-flex mx-auto">
|
||||
<v-row no-gutters class="d-flex flex-no-wrap align-center " :style="fontSize">
|
||||
<v-icon :x-large="!small" left color="primary">
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
<p class="my-0"><span class="font-weight-bold">{{ validateTotalTime.name }}</span><br>{{ validateTotalTime.value }}</p>
|
||||
</v-row>
|
||||
</div>
|
||||
<v-divider v-if="validateTotalTime && (validatePrepTime || validatePerformTime)" class="my-2" />
|
||||
<!-- Prep Time & Perform Time -->
|
||||
<div v-if="validatePrepTime || validatePerformTime" class="time-card-flex mx-auto">
|
||||
<div
|
||||
v-if="validateTotalTime"
|
||||
class="time-card-flex mx-auto"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
class="d-flex justify-center align-center" :class="{'flex-column': $vuetify.breakpoint.smAndDown}"
|
||||
style="width: 100%;" :style="fontSize"
|
||||
class="d-flex flex-no-wrap align-center"
|
||||
:style="fontSize"
|
||||
>
|
||||
<div v-if="validatePrepTime" class="d-flex flex-no-wrap my-1">
|
||||
<v-icon :large="!small" :dense="small" left color="primary">
|
||||
<v-icon
|
||||
:x-large="!small"
|
||||
start
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.clockOutline }}
|
||||
</v-icon>
|
||||
<p class="my-0">
|
||||
<span class="font-weight-bold opacity-80">{{ validateTotalTime.name }}</span><br>{{ validateTotalTime.value }}
|
||||
</p>
|
||||
</v-row>
|
||||
</div>
|
||||
<v-divider
|
||||
v-if="validateTotalTime && (validatePrepTime || validatePerformTime)"
|
||||
class="my-2"
|
||||
/>
|
||||
<!-- Prep Time & Perform Time -->
|
||||
<div
|
||||
v-if="validatePrepTime || validatePerformTime"
|
||||
class="time-card-flex mx-auto"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
class="d-flex justify-center align-center"
|
||||
:class="{ 'flex-column': $vuetify.display.smAndDown }"
|
||||
style="width: 100%;"
|
||||
:style="fontSize"
|
||||
>
|
||||
<div
|
||||
v-if="validatePrepTime"
|
||||
class="d-flex flex-no-wrap my-1 align-center"
|
||||
>
|
||||
<v-icon
|
||||
:size="small ? 'small' : 'large'"
|
||||
left
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.knfife }}
|
||||
</v-icon>
|
||||
<p class="my-0"><span class="font-weight-bold">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}</p>
|
||||
<p class="my-0">
|
||||
<span class="font-weight-bold opacity-80">{{ validatePrepTime.name }}</span><br>{{ validatePrepTime.value }}
|
||||
</p>
|
||||
</div>
|
||||
<v-divider v-if="validatePrepTime && validatePerformTime" vertical class="mx-4" />
|
||||
<div v-if="validatePerformTime" class="d-flex flex-no-wrap my-1">
|
||||
<v-icon :large="!small" :dense="small" left color="primary">
|
||||
<v-divider
|
||||
v-if="validatePrepTime && validatePerformTime"
|
||||
vertical
|
||||
class="mx-4"
|
||||
/>
|
||||
<div
|
||||
v-if="validatePerformTime"
|
||||
class="d-flex flex-no-wrap my-1 align-center"
|
||||
>
|
||||
<v-icon
|
||||
:size="small ? 'small' : 'large'"
|
||||
left
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.potSteam }}
|
||||
</v-icon>
|
||||
<p class="my-0"><span class="font-weight-bold">{{ validatePerformTime.name }}</span><br>{{ validatePerformTime.value }}</p>
|
||||
<p class="my-0">
|
||||
<span class="font-weight-bold opacity-80">{{ validatePerformTime.name }}</span><br>{{ validatePerformTime.value }}
|
||||
</p>
|
||||
</div>
|
||||
</v-row>
|
||||
</div>
|
||||
|
@ -36,9 +79,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
prepTime: {
|
||||
type: String,
|
||||
|
@ -54,7 +95,7 @@ export default defineComponent({
|
|||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
default: "accent custom-transparent",
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
|
@ -62,14 +103,14 @@ export default defineComponent({
|
|||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
|
||||
function isEmpty(str: string | null) {
|
||||
return !str || str.length === 0;
|
||||
}
|
||||
|
||||
const showCards = computed(() => {
|
||||
return [props.prepTime, props.totalTime, props.performTime].some((x) => !isEmpty(x));
|
||||
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
|
||||
});
|
||||
|
||||
const validateTotalTime = computed(() => {
|
||||
|
|
|
@ -4,55 +4,62 @@
|
|||
<v-spacer />
|
||||
<v-col class="text-right">
|
||||
<!-- Filters -->
|
||||
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-badge :content="filterBadgeCount" :value="filterBadgeCount" bordered overlap>
|
||||
<v-btn fab small color="info" v-bind="attrs" v-on="on">
|
||||
<v-menu
|
||||
offset-y
|
||||
bottom
|
||||
start
|
||||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-badge
|
||||
:content="filterBadgeCount"
|
||||
:model-value="filterBadgeCount > 0"
|
||||
bordered
|
||||
>
|
||||
<v-btn
|
||||
class="rounded-circle"
|
||||
size="small"
|
||||
color="info"
|
||||
v-bind="props"
|
||||
icon
|
||||
>
|
||||
<v-icon> {{ $globals.icons.filter }} </v-icon>
|
||||
</v-btn>
|
||||
</v-badge>
|
||||
</template>
|
||||
<v-card>
|
||||
<v-list>
|
||||
<v-list-item @click="reverseSort">
|
||||
<v-icon left>
|
||||
{{
|
||||
preferences.orderDirection === "asc" ?
|
||||
$globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending
|
||||
}}
|
||||
</v-icon>
|
||||
<v-list-item-title>
|
||||
{{ preferences.orderDirection === "asc" ? $tc("general.sort-descending") : $tc("general.sort-ascending") }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
:prepend-icon="preferences.orderDirection === 'asc' ? $globals.icons.sortCalendarDescending : $globals.icons.sortCalendarAscending"
|
||||
:title="preferences.orderDirection === 'asc' ? $t('general.sort-descending') : $t('general.sort-ascending')"
|
||||
@click="reverseSort"
|
||||
/>
|
||||
<v-divider />
|
||||
<v-list-item class="pa-0">
|
||||
<v-list class="py-0" style="width: 100%;">
|
||||
<v-list-item
|
||||
v-for="option, idx in eventTypeFilterState"
|
||||
:key="idx"
|
||||
>
|
||||
<v-checkbox
|
||||
:input-value="option.checked"
|
||||
readonly
|
||||
@click="toggleEventTypeOption(option.value)"
|
||||
>
|
||||
<template #label>
|
||||
<v-icon left>
|
||||
{{ option.icon }}
|
||||
</v-icon>
|
||||
{{ option.label }}
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-list-item
|
||||
v-for="option, idx in eventTypeFilterState"
|
||||
:key="idx"
|
||||
>
|
||||
<v-checkbox
|
||||
:model-value="option.checked"
|
||||
color="primary"
|
||||
readonly
|
||||
@click="toggleEventTypeOption(option.value)"
|
||||
>
|
||||
<template #label>
|
||||
<v-icon start>
|
||||
{{ option.icon }}
|
||||
</v-icon>
|
||||
{{ option.label }}
|
||||
</template>
|
||||
</v-checkbox>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-divider class="mx-2"/>
|
||||
<v-divider class="mx-2" />
|
||||
<div
|
||||
v-if="timelineEvents.length"
|
||||
id="timeline-container"
|
||||
|
@ -61,7 +68,10 @@
|
|||
class="px-1"
|
||||
:style="maxHeight ? `max-height: ${maxHeight}; overflow-y: auto;` : ''"
|
||||
>
|
||||
<v-timeline :dense="$vuetify.breakpoint.smAndDown" class="timeline">
|
||||
<v-timeline
|
||||
:dense="$vuetify.display.smAndDown"
|
||||
class="timeline"
|
||||
>
|
||||
<RecipeTimelineItem
|
||||
v-for="(event, index) in timelineEvents"
|
||||
:key="event.id"
|
||||
|
@ -73,33 +83,41 @@
|
|||
/>
|
||||
</v-timeline>
|
||||
</div>
|
||||
<v-card v-else-if="!loading" class="mt-2">
|
||||
<v-card
|
||||
v-else-if="!loading"
|
||||
class="mt-2"
|
||||
>
|
||||
<v-card-title class="justify-center pa-9">
|
||||
{{ $t("recipe.timeline-no-events-found-try-adjusting-filters") }}
|
||||
</v-card-title>
|
||||
</v-card>
|
||||
<div v-if="loading" class="mb-3 text-center">
|
||||
<AppLoader :loading="loading" :waiting-text="$tc('general.loading-events')" />
|
||||
<div
|
||||
v-if="loading"
|
||||
class="mb-3 text-center"
|
||||
>
|
||||
<AppLoader
|
||||
:loading="loading"
|
||||
:waiting-text="$t('general.loading-events')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, ref, useAsync, useContext } from "@nuxtjs/composition-api";
|
||||
import { useThrottleFn, whenever } from "@vueuse/core";
|
||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue"
|
||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue";
|
||||
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
||||
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
||||
import { useAsyncKey } from "~/composables/use-utils";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeTimelineItem },
|
||||
|
||||
props: {
|
||||
value: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
@ -114,12 +132,12 @@ export default defineComponent({
|
|||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const { i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const preferences = useTimelinePreferences();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const loading = ref(true);
|
||||
|
@ -133,16 +151,16 @@ export default defineComponent({
|
|||
const recipes = new Map<string, Recipe>();
|
||||
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length);
|
||||
const eventTypeFilterState = computed(() => {
|
||||
return eventTypeOptions.value.map(option => {
|
||||
return eventTypeOptions.value.map((option) => {
|
||||
return {
|
||||
...option,
|
||||
checked: preferences.value.types.includes(option.value),
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
interface ScrollEvent extends Event {
|
||||
target: HTMLInputElement;
|
||||
target: HTMLInputElement;
|
||||
}
|
||||
|
||||
const screenBuffer = 4;
|
||||
|
@ -154,17 +172,17 @@ export default defineComponent({
|
|||
const { scrollTop, offsetHeight, scrollHeight } = event.target;
|
||||
|
||||
// trigger when the user is getting close to the bottom
|
||||
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight*screenBuffer);
|
||||
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer);
|
||||
if (bottomOfElement) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
|
||||
whenever(
|
||||
() => props.value,
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Preferences
|
||||
|
@ -173,7 +191,7 @@ export default defineComponent({
|
|||
return;
|
||||
}
|
||||
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
|
@ -185,7 +203,8 @@ export default defineComponent({
|
|||
const index = preferences.value.types.indexOf(option);
|
||||
if (index === -1) {
|
||||
preferences.value.types.push(option);
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
preferences.value.types.splice(index, 1);
|
||||
}
|
||||
|
||||
|
@ -194,21 +213,21 @@ export default defineComponent({
|
|||
|
||||
// Timeline Actions
|
||||
async function updateTimelineEvent(index: number) {
|
||||
const event = timelineEvents.value[index]
|
||||
const event = timelineEvents.value[index];
|
||||
const payload: RecipeTimelineEventUpdate = {
|
||||
subject: event.subject,
|
||||
eventMessage: event.eventMessage,
|
||||
image: event.image,
|
||||
};
|
||||
|
||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
const { response } = await api.recipes.updateTimelineEvent(event.id, payload);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
};
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
};
|
||||
|
||||
async function deleteTimelineEvent(index: number) {
|
||||
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
|
||||
|
@ -223,35 +242,35 @@ export default defineComponent({
|
|||
|
||||
async function getRecipe(recipeId: string): Promise<Recipe | null> {
|
||||
const { data } = await api.recipes.getOne(recipeId);
|
||||
return data
|
||||
return data;
|
||||
};
|
||||
|
||||
async function updateRecipes(events: RecipeTimelineEventOut[]) {
|
||||
const recipePromises: Promise<Recipe | null>[] = [];
|
||||
const seenRecipeIds: string[] = [];
|
||||
events.forEach(event => {
|
||||
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
}
|
||||
const recipePromises: Promise<Recipe | null>[] = [];
|
||||
const seenRecipeIds: string[] = [];
|
||||
events.forEach((event) => {
|
||||
if (seenRecipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seenRecipeIds.push(event.recipeId);
|
||||
recipePromises.push(getRecipe(event.recipeId));
|
||||
})
|
||||
seenRecipeIds.push(event.recipeId);
|
||||
recipePromises.push(getRecipe(event.recipeId));
|
||||
});
|
||||
|
||||
const results = await Promise.all(recipePromises);
|
||||
results.forEach(result => {
|
||||
if (result && result.id) {
|
||||
recipes.set(result.id, result);
|
||||
}
|
||||
})
|
||||
const results = await Promise.all(recipePromises);
|
||||
results.forEach((result) => {
|
||||
if (result && result.id) {
|
||||
recipes.set(result.id, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollTimelineEvents() {
|
||||
const orderBy = "timestamp";
|
||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||
// eslint-disable-next-line quotes
|
||||
const eventTypeValue = `["${preferences.value.types.join('", "')}"]`;
|
||||
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`
|
||||
|
||||
const eventTypeValue = `["${preferences.value.types.join("\", \"")}"]`;
|
||||
const queryFilter = `(${props.queryFilter}) AND eventType IN ${eventTypeValue}`;
|
||||
|
||||
const response = await api.recipes.getAllTimelineEvents(page.value, perPage, { orderBy, orderDirection, queryFilter });
|
||||
page.value += 1;
|
||||
|
@ -290,7 +309,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsync(async () => {
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
@ -298,7 +317,7 @@ export default defineComponent({
|
|||
loading.value = true;
|
||||
await scrollTimelineEvents();
|
||||
loading.value = false;
|
||||
}, useAsyncKey());
|
||||
});
|
||||
}, 500);
|
||||
|
||||
// preload events
|
||||
|
@ -310,7 +329,7 @@ export default defineComponent({
|
|||
// if the inner element is scrollable, let its scroll event handle the infiniteScroll
|
||||
const timelineContainerElement = document.getElementById("timeline-container");
|
||||
if (timelineContainerElement) {
|
||||
const { clientHeight, scrollHeight } = timelineContainerElement
|
||||
const { clientHeight, scrollHeight } = timelineContainerElement;
|
||||
|
||||
// if scrollHeight == clientHeight, the element is not scrollable, so we need to look at the global position
|
||||
// if scrollHeight > clientHeight, it is scrollable and we don't need to do anything here
|
||||
|
@ -319,13 +338,13 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight*screenBuffer);
|
||||
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
|
||||
if (bottomOfWindow) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
deleteTimelineEvent,
|
||||
|
|
|
@ -1,32 +1,48 @@
|
|||
<template>
|
||||
<v-tooltip bottom nudge-right="50" :color="buttonStyle ? 'info' : 'secondary'">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-tooltip
|
||||
bottom
|
||||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
small
|
||||
icon
|
||||
:variant="buttonStyle ? 'flat' : undefined"
|
||||
:rounded="buttonStyle ? 'circle' : undefined"
|
||||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:fab="buttonStyle"
|
||||
class="ml-1"
|
||||
v-bind="attrs"
|
||||
v-on="on"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
@click.prevent="toggleTimeline"
|
||||
>
|
||||
<v-icon :small="!buttonStyle" :color="buttonStyle ? 'white' : 'secondary'">
|
||||
<v-icon
|
||||
:size="!buttonStyle ? undefined : 'x-large'"
|
||||
:color="buttonStyle ? 'white' : 'secondary'"
|
||||
>
|
||||
{{ $globals.icons.timelineText }}
|
||||
</v-icon>
|
||||
</v-btn>
|
||||
<BaseDialog v-model="showTimeline" :title="timelineAttrs.title" :icon="$globals.icons.timelineText" width="70%">
|
||||
<RecipeTimeline v-model="showTimeline" :query-filter="timelineAttrs.queryFilter" max-height="60vh" />
|
||||
<BaseDialog
|
||||
v-model="showTimeline"
|
||||
:title="timelineAttrs.title"
|
||||
:icon="$globals.icons.timelineText"
|
||||
width="70%"
|
||||
>
|
||||
<RecipeTimeline
|
||||
v-model="showTimeline"
|
||||
:query-filter="timelineAttrs.queryFilter"
|
||||
max-height="60vh"
|
||||
/>
|
||||
</BaseDialog>
|
||||
|
||||
</template>
|
||||
<span>{{ $t('recipe.open-timeline') }}</span>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import RecipeTimeline from "./RecipeTimeline.vue";
|
||||
export default defineComponent({
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeTimeline },
|
||||
|
||||
props: {
|
||||
|
@ -45,23 +61,24 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
setup(props) {
|
||||
const { $vuetify, i18n } = useContext();
|
||||
const i18n = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const showTimeline = ref(false);
|
||||
function toggleTimeline() {
|
||||
showTimeline.value = !showTimeline.value;
|
||||
}
|
||||
|
||||
const timelineAttrs = computed(() => {
|
||||
let title = i18n.tc("recipe.timeline")
|
||||
if ($vuetify.breakpoint.smAndDown) {
|
||||
title += ` – ${props.recipeName}`
|
||||
let title = i18n.t("recipe.timeline");
|
||||
if (smAndDown.value) {
|
||||
title += ` – ${props.recipeName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
queryFilter: `recipe.slug="${props.slug}"`,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
return { showTimeline, timelineAttrs, toggleTimeline };
|
||||
},
|
||||
|
|
|
@ -2,58 +2,69 @@
|
|||
<div class="text-center">
|
||||
<BaseDialog
|
||||
v-model="recipeEventEditDialog"
|
||||
:title="$tc('recipe.edit-timeline-event')"
|
||||
:title="$t('recipe.edit-timeline-event')"
|
||||
:icon="$globals.icons.edit"
|
||||
:submit-text="$tc('general.save')"
|
||||
@submit="$emit('update')"
|
||||
can-submit
|
||||
:submit-text="$t('general.save')"
|
||||
@submit="submitEdit"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-form ref="domMadeThisForm">
|
||||
<v-text-field
|
||||
v-model="event.subject"
|
||||
:label="$tc('general.subject')"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="event.eventMessage"
|
||||
:label="$tc('general.message')"
|
||||
rows="4"
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-text>
|
||||
<v-form ref="domEditEventForm">
|
||||
<v-text-field v-model="localEvent.subject" :label="$t('general.subject')" />
|
||||
<v-textarea v-model="localEvent.eventMessage" :label="$t('general.message')" rows="4" />
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<BaseDialog
|
||||
v-model="recipeEventDeleteDialog"
|
||||
:title="$tc('events.delete-event')"
|
||||
:title="$t('events.delete-event')"
|
||||
color="error"
|
||||
:icon="$globals.icons.alertCircle"
|
||||
can-confirm
|
||||
@confirm="$emit('delete')"
|
||||
>
|
||||
<v-card-text>
|
||||
{{ $t("events.event-delete-confirmation") }}
|
||||
{{ $t('events.event-delete-confirmation') }}
|
||||
</v-card-text>
|
||||
</BaseDialog>
|
||||
|
||||
<v-menu
|
||||
offset-y
|
||||
left
|
||||
:bottom="!menuTop"
|
||||
:nudge-bottom="!menuTop ? '5' : '0'"
|
||||
:top="menuTop"
|
||||
:nudge-top="menuTop ? '5' : '0'"
|
||||
start
|
||||
:bottom="!props.menuTop"
|
||||
:nudge-bottom="!props.menuTop ? '5' : '0'"
|
||||
:top="props.menuTop"
|
||||
:nudge-top="props.menuTop ? '5' : '0'"
|
||||
allow-overflow
|
||||
close-delay="125"
|
||||
:open-on-hover="!useMobileFormat"
|
||||
:open-on-hover="!props.useMobileFormat"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn :fab="fab" :x-small="fab" :elevation="elevation" :color="color" :icon="!fab" v-bind="attrs" v-on="on" @click.prevent>
|
||||
<template #activator="{ props: btnProps }">
|
||||
<v-btn
|
||||
:class="{ 'rounded-circle': props.fab }"
|
||||
:x-small="props.fab"
|
||||
:elevation="props.elevation ?? undefined"
|
||||
:color="props.color"
|
||||
:icon="!props.fab"
|
||||
v-bind="btnProps"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon>{{ icon }}</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list dense>
|
||||
<v-list-item v-for="(item, index) in menuItems" :key="index" @click="contextMenuEventHandler(item.event)">
|
||||
<v-list-item-icon>
|
||||
<v-icon :color="item.color"> {{ item.icon }} </v-icon>
|
||||
</v-list-item-icon>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="(item, index) in menuItems"
|
||||
:key="index"
|
||||
@click="contextMenuEventHandler(item.event)"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon :color="item.color">
|
||||
{{ item.icon }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
|
@ -61,10 +72,9 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, reactive, ref, toRefs, useContext } from "@nuxtjs/composition-api";
|
||||
import { VForm } from "~/types/vuetify";
|
||||
import { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
<script lang="ts" setup>
|
||||
import { useI18n, useNuxtApp } from "#imports";
|
||||
import type { RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
|
||||
export interface TimelineContextMenuIncludes {
|
||||
edit: boolean;
|
||||
|
@ -78,129 +88,90 @@ export interface ContextMenuItem {
|
|||
event: string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
useItems: {
|
||||
type: Object as () => TimelineContextMenuIncludes,
|
||||
default: () => ({
|
||||
edit: true,
|
||||
delete: true,
|
||||
}),
|
||||
},
|
||||
// Append items are added at the end of the useItems list
|
||||
appendItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
// Append items are added at the beginning of the useItems list
|
||||
leadingItems: {
|
||||
type: Array as () => ContextMenuItem[],
|
||||
default: () => [],
|
||||
},
|
||||
menuTop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
fab: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
elevation: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
event: {
|
||||
type: Object as () => RecipeTimelineEventOut,
|
||||
required: true,
|
||||
},
|
||||
menuIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
useMobileFormat: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
const props = defineProps<{
|
||||
useItems?: TimelineContextMenuIncludes;
|
||||
appendItems?: ContextMenuItem[];
|
||||
leadingItems?: ContextMenuItem[];
|
||||
menuTop?: boolean;
|
||||
fab?: boolean;
|
||||
elevation?: number | null;
|
||||
color?: string;
|
||||
event: RecipeTimelineEventOut;
|
||||
menuIcon?: string | null;
|
||||
useMobileFormat?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["delete", "update"]);
|
||||
|
||||
const domEditEventForm = ref();
|
||||
const recipeEventEditDialog = ref(false);
|
||||
const recipeEventDeleteDialog = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
},
|
||||
setup(props, context) {
|
||||
const domEditEventForm = ref<VForm>();
|
||||
const state = reactive({
|
||||
recipeEventEditDialog: false,
|
||||
recipeEventDeleteDialog: false,
|
||||
loading: false,
|
||||
menuItems: [] as ContextMenuItem[],
|
||||
});
|
||||
|
||||
const { i18n, $globals } = useContext();
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Setup
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.tc("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
},
|
||||
delete: {
|
||||
title: i18n.tc("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: "error",
|
||||
event: "delete",
|
||||
},
|
||||
};
|
||||
|
||||
// Get Default Menu Items Specified in Props
|
||||
for (const [key, value] of Object.entries(props.useItems)) {
|
||||
if (value) {
|
||||
const item = defaultItems[key];
|
||||
if (item) {
|
||||
state.menuItems.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add Leading and Appending Items
|
||||
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems];
|
||||
|
||||
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Event Handler
|
||||
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
edit: () => {
|
||||
state.recipeEventEditDialog = true;
|
||||
},
|
||||
delete: () => {
|
||||
state.recipeEventDeleteDialog = true;
|
||||
},
|
||||
};
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
const handler = eventHandlers[eventKey];
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handler();
|
||||
state.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
context.emit(eventKey);
|
||||
state.loading = false;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
contextMenuEventHandler,
|
||||
domEditEventForm,
|
||||
icon,
|
||||
};
|
||||
delete: {
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: "error",
|
||||
event: "delete",
|
||||
},
|
||||
};
|
||||
|
||||
const menuItems = computed(() => {
|
||||
const items: ContextMenuItem[] = [];
|
||||
const useItems = props.useItems ?? { edit: true, delete: true };
|
||||
for (const [key, value] of Object.entries(useItems)) {
|
||||
if (value) {
|
||||
const item = defaultItems[key];
|
||||
if (item) items.push(item);
|
||||
}
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
...(props.leadingItems ?? []),
|
||||
...(props.appendItems ?? []),
|
||||
];
|
||||
});
|
||||
|
||||
const icon = computed(() => props.menuIcon || $globals.icons.dotsVertical);
|
||||
|
||||
const localEvent = ref({ ...props.event });
|
||||
watch(() => props.event, (val) => {
|
||||
localEvent.value = { ...val };
|
||||
});
|
||||
|
||||
function openEditDialog() {
|
||||
localEvent.value = { ...props.event };
|
||||
recipeEventEditDialog.value = true;
|
||||
}
|
||||
function openDeleteDialog() {
|
||||
recipeEventDeleteDialog.value = true;
|
||||
}
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
if (eventKey === "edit") {
|
||||
openEditDialog();
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
if (eventKey === "delete") {
|
||||
openDeleteDialog();
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
emit(eventKey as "delete" | "update");
|
||||
loading.value = false;
|
||||
}
|
||||
function submitEdit() {
|
||||
emit("update", { ...localEvent.value });
|
||||
recipeEventEditDialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,61 +1,57 @@
|
|||
<template>
|
||||
<v-timeline-item
|
||||
:class="attrs.class"
|
||||
fill-dot
|
||||
:small="attrs.small"
|
||||
:icon="icon"
|
||||
>
|
||||
<v-timeline-item :class="attrs.class" fill-dot :small="attrs.small" :icon="icon" dot-color="primary">
|
||||
<template v-if="!useMobileFormat" #opposite>
|
||||
<v-chip v-if="event.timestamp" label large>
|
||||
<v-icon class="mr-1"> {{ $globals.icons.calendar }} </v-icon>
|
||||
<v-icon class="mr-1">
|
||||
{{ $globals.icons.calendar }}
|
||||
</v-icon>
|
||||
{{ new Date(event.timestamp).toLocaleDateString($i18n.locale) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-card
|
||||
hover
|
||||
:to="$listeners.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`"
|
||||
:to="$attrs.selected || !recipe ? undefined : `/g/${groupSlug}/r/${recipe.slug}`"
|
||||
class="elevation-12"
|
||||
@click="$emit('selected')"
|
||||
>
|
||||
<v-card-title class="background">
|
||||
<v-row>
|
||||
<v-col align-self="center" :cols="useMobileFormat ? 'auto' : '2'" :class="attrs.avatar.class">
|
||||
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
|
||||
<UserAvatar :user-id="event.userId" :size="attrs.avatar.size" />
|
||||
</v-col>
|
||||
<v-col v-if="useMobileFormat" align-self="center" class="pr-0">
|
||||
<v-chip label>
|
||||
<v-chip label>
|
||||
<v-icon> {{ $globals.icons.calendar }} </v-icon>
|
||||
{{ new Date(event.timestamp || "").toLocaleDateString($i18n.locale) }}
|
||||
</v-chip>
|
||||
</v-chip>
|
||||
</v-col>
|
||||
<v-col v-else cols="9" style="margin: auto; text-align: center;">
|
||||
{{ event.subject }}
|
||||
<v-col v-else cols="9" style="margin: auto; text-align: center">
|
||||
{{ event.subject }}
|
||||
</v-col>
|
||||
<v-spacer />
|
||||
<v-col :cols="useMobileFormat ? 'auto' : '1'" class="px-0 pt-0">
|
||||
<RecipeTimelineContextMenu
|
||||
v-if="$auth.user && $auth.user.id == event.userId && event.eventType != 'system'"
|
||||
:menu-top="false"
|
||||
:event="event"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:use-mobile-format="useMobileFormat"
|
||||
fab
|
||||
color="transparent"
|
||||
:elevation="0"
|
||||
:card-menu="false"
|
||||
:use-items="{
|
||||
edit: true,
|
||||
delete: true,
|
||||
}"
|
||||
@update="$emit('update')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
<RecipeTimelineContextMenu
|
||||
v-if="currentUser && currentUser.id == event.userId && event.eventType != 'system'"
|
||||
:menu-top="false"
|
||||
:event="event"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
:use-mobile-format="useMobileFormat"
|
||||
color="transparent"
|
||||
:elevation="0"
|
||||
:card-menu="false"
|
||||
:use-items="{
|
||||
edit: true,
|
||||
delete: true,
|
||||
}"
|
||||
@update="$emit('update')"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-title>
|
||||
<v-card-text v-if="showRecipeCards && recipe" class="background">
|
||||
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%;">
|
||||
<v-col align-self="center" class="pa-0">
|
||||
<v-row :class="useMobileFormat ? 'py-3 mx-0' : 'py-3 mx-0'" style="max-width: 100%">
|
||||
<v-col align-self="center" class="pa-0">
|
||||
<RecipeCardMobile
|
||||
:vertical="useMobileFormat"
|
||||
:name="recipe.name"
|
||||
|
@ -67,26 +63,26 @@
|
|||
:is-flat="true"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<v-divider v-if="showRecipeCards && recipe && (useMobileFormat || event.eventMessage)" />
|
||||
<v-card-text class="background">
|
||||
<v-row>
|
||||
<v-col>
|
||||
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
|
||||
<v-img
|
||||
v-if="eventImageUrl"
|
||||
:src="eventImageUrl"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : 'auto'"
|
||||
:max-height="attrs.image.maxHeight"
|
||||
contain
|
||||
:class=attrs.image.class
|
||||
@error="hideImage = true"
|
||||
/>
|
||||
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
|
||||
<SafeMarkdown :source="event.eventMessage" />
|
||||
</div>
|
||||
<strong v-if="useMobileFormat">{{ event.subject }}</strong>
|
||||
<v-img
|
||||
v-if="eventImageUrl"
|
||||
:src="eventImageUrl"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : 'auto'"
|
||||
:max-height="attrs.image.maxHeight"
|
||||
contain
|
||||
:class="attrs.image.class"
|
||||
@error="hideImage = true"
|
||||
/>
|
||||
<div v-if="event.eventMessage" :class="useMobileFormat ? 'text-caption' : ''">
|
||||
<SafeMarkdown :source="event.eventMessage" />
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
@ -95,16 +91,15 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, ref, useContext, useRoute } from "@nuxtjs/composition-api";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
import { useTimelineEventTypes } from "~/composables/recipes/use-recipe-timeline-events";
|
||||
import { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe"
|
||||
import type { Recipe, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
|
||||
import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
|
||||
|
||||
props: {
|
||||
|
@ -119,20 +114,23 @@ export default defineComponent({
|
|||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
},
|
||||
emits: ["selected", "update", "delete"],
|
||||
|
||||
setup(props) {
|
||||
const { $auth, $globals, $vuetify } = useContext();
|
||||
const { $vuetify, $globals } = useNuxtApp();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||
|
||||
const { user: currentUser } = useMealieAuth();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
|
||||
|
||||
const useMobileFormat = computed(() => {
|
||||
return $vuetify.breakpoint.smAndDown;
|
||||
return $vuetify.display.smAndDown.value;
|
||||
});
|
||||
|
||||
const attrs = computed(() => {
|
||||
|
@ -146,9 +144,9 @@ export default defineComponent({
|
|||
},
|
||||
image: {
|
||||
maxHeight: "250",
|
||||
class: "my-3"
|
||||
class: "my-3",
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
|
@ -160,25 +158,25 @@ export default defineComponent({
|
|||
},
|
||||
image: {
|
||||
maxHeight: "300",
|
||||
class: "mb-5"
|
||||
class: "mb-5",
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
const option = eventTypeOptions.value.find((option) => option.value === props.event.eventType);
|
||||
const option = eventTypeOptions.value.find(option => option.value === props.event.eventType);
|
||||
return option ? option.icon : $globals.icons.informationVariant;
|
||||
});
|
||||
|
||||
const hideImage = ref(false);
|
||||
const eventImageUrl = computed<string>( () => {
|
||||
const eventImageUrl = computed<string>(() => {
|
||||
if (props.event.image !== "has image") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
attrs,
|
||||
|
@ -188,6 +186,7 @@ export default defineComponent({
|
|||
hideImage,
|
||||
timelineEvents,
|
||||
useMobileFormat,
|
||||
currentUser,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,24 +1,34 @@
|
|||
<template>
|
||||
<div v-if="scaledAmount" class="d-flex align-center">
|
||||
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger;">
|
||||
<v-icon x-large left color="primary">
|
||||
<div
|
||||
v-if="scaledAmount"
|
||||
class="d-flex align-center"
|
||||
>
|
||||
<v-row
|
||||
no-gutters
|
||||
class="d-flex flex-wrap align-center"
|
||||
style="font-size: larger;"
|
||||
>
|
||||
<v-icon
|
||||
size="x-large"
|
||||
start
|
||||
color="primary"
|
||||
>
|
||||
{{ $globals.icons.bread }}
|
||||
</v-icon>
|
||||
<p class="my-0">
|
||||
<span class="font-weight-bold">{{ $i18n.tc("recipe.yield") }}</span><br>
|
||||
<p class="my-0 opacity-80">
|
||||
<span class="font-weight-bold">{{ $t("recipe.yield") }}</span><br>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span v-html="scaledAmount"></span> {{ text }}
|
||||
<span v-html="scaledAmount" /> {{ text }}
|
||||
</p>
|
||||
</v-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from "@nuxtjs/composition-api";
|
||||
import DOMPurify from "dompurify";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineComponent({
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
yieldQuantity: {
|
||||
type: Number,
|
||||
|
@ -34,11 +44,10 @@ export default defineComponent({
|
|||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent"
|
||||
default: "accent custom-transparent",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
|
@ -47,7 +56,7 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
const scaledAmount = computed(() => {
|
||||
const {scaledAmountDisplay} = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
return scaledAmountDisplay;
|
||||
});
|
||||
const text = sanitizeHTML(props.yield);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue