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

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:
Hoa (Kyle) Trinh 2025-06-20 00:09:12 +07:00 committed by GitHub
commit c24d532608
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
403 changed files with 23959 additions and 19557 deletions

View file

@ -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 {

View file

@ -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;
}

View 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;

View file

@ -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) {

View file

@ -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>

View file

@ -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() {

View file

@ -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 = "...") {

View file

@ -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,

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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,
},
];

View file

@ -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>

View file

@ -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() {

View file

@ -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,

View file

@ -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>

View file

@ -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();
}

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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(() => {

View file

@ -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,

View file

@ -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>&frasl;<sub>${fraction[2]}</sub>`;
}
else {
listItemDescription = (quantity).toString();
}
if (fraction[1] > 0) {
listItemDescription += ` <sup>${fraction[1]}</sup>&frasl;<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;

View file

@ -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>

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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;
});

View file

@ -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;
}

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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");
}
}

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -1,3 +0,0 @@
import RecipePage from "./RecipePage.vue";
export default RecipePage;

View file

@ -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,
},

View file

@ -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>

View file

@ -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 {

View file

@ -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;

View file

@ -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 = {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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(() => {

View file

@ -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,

View file

@ -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 };
},

View file

@ -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>

View file

@ -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,
};
},
});

View file

@ -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);