mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-04 21:15:22 +02:00
parent
f2b6512eb1
commit
f26e74f0f2
43 changed files with 2761 additions and 3642 deletions
|
@ -18,7 +18,7 @@
|
|||
<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" location="bottom" color="info">
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
icon
|
||||
variant="flat"
|
||||
|
@ -26,7 +26,7 @@
|
|||
size="small"
|
||||
color="info"
|
||||
class="ml-1"
|
||||
v-bind="props"
|
||||
v-bind="tooltipProps"
|
||||
@click="$emit('edit', true)"
|
||||
>
|
||||
<v-icon size="x-large">
|
||||
|
@ -86,7 +86,7 @@
|
|||
</v-toolbar>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
|
||||
|
@ -97,103 +97,75 @@ const DELETE_EVENT = "delete";
|
|||
const CLOSE_EVENT = "close";
|
||||
const JSON_EVENT = "json";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge },
|
||||
props: {
|
||||
recipe: {
|
||||
required: true,
|
||||
type: Object as () => Recipe,
|
||||
},
|
||||
slug: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
recipeScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
open: {
|
||||
required: true,
|
||||
type: Boolean,
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
loggedIn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipeId: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
canEdit: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["print", "input", "delete", "close", "edit"],
|
||||
setup(_, context) {
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
const editorButtons = [
|
||||
{
|
||||
text: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
event: DELETE_EVENT,
|
||||
color: "error",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.json"),
|
||||
icon: $globals.icons.codeBraces,
|
||||
event: JSON_EVENT,
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.close"),
|
||||
icon: $globals.icons.close,
|
||||
event: CLOSE_EVENT,
|
||||
color: "",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
event: SAVE_EVENT,
|
||||
color: "success",
|
||||
},
|
||||
];
|
||||
|
||||
function emitHandler(event: string) {
|
||||
switch (event) {
|
||||
case CLOSE_EVENT:
|
||||
context.emit(CLOSE_EVENT);
|
||||
context.emit("input", false);
|
||||
break;
|
||||
case DELETE_EVENT:
|
||||
deleteDialog.value = true;
|
||||
break;
|
||||
default:
|
||||
context.emit(event);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function emitDelete() {
|
||||
context.emit(DELETE_EVENT);
|
||||
context.emit("input", false);
|
||||
}
|
||||
|
||||
return {
|
||||
deleteDialog,
|
||||
editorButtons,
|
||||
emitHandler,
|
||||
emitDelete,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe: Recipe;
|
||||
slug: string;
|
||||
recipeScale?: number;
|
||||
open: boolean;
|
||||
name: string;
|
||||
loggedIn?: boolean;
|
||||
recipeId: string;
|
||||
canEdit?: boolean;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
recipeScale: 1,
|
||||
loggedIn: false,
|
||||
canEdit: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits(["print", "input", "delete", "close", "edit"]);
|
||||
|
||||
const deleteDialog = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const { $globals } = useNuxtApp();
|
||||
|
||||
const editorButtons = [
|
||||
{
|
||||
text: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
event: DELETE_EVENT,
|
||||
color: "error",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.json"),
|
||||
icon: $globals.icons.codeBraces,
|
||||
event: JSON_EVENT,
|
||||
color: "accent",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.close"),
|
||||
icon: $globals.icons.close,
|
||||
event: CLOSE_EVENT,
|
||||
color: "",
|
||||
},
|
||||
{
|
||||
text: i18n.t("general.save"),
|
||||
icon: $globals.icons.save,
|
||||
event: SAVE_EVENT,
|
||||
color: "success",
|
||||
},
|
||||
];
|
||||
|
||||
function emitHandler(event: string) {
|
||||
switch (event) {
|
||||
case CLOSE_EVENT:
|
||||
emit("close");
|
||||
emit("input", false);
|
||||
break;
|
||||
case DELETE_EVENT:
|
||||
deleteDialog.value = true;
|
||||
break;
|
||||
default:
|
||||
emit(event as any);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function emitDelete() {
|
||||
emit("delete");
|
||||
emit("input", false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -2,11 +2,11 @@
|
|||
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
|
||||
<div>
|
||||
<v-hover
|
||||
v-slot="{ isHovering, props }"
|
||||
v-slot="{ isHovering, props: hoverProps }"
|
||||
:open-delay="50"
|
||||
>
|
||||
<v-card
|
||||
v-bind="props"
|
||||
v-bind="hoverProps"
|
||||
:class="{ 'on-hover': isHovering }"
|
||||
:style="{ cursor }"
|
||||
:elevation="isHovering ? 12 : 2"
|
||||
|
@ -100,7 +100,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeChips from "./RecipeChips.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
|
@ -108,69 +108,41 @@ import RecipeCardImage from "./RecipeCardImage.vue";
|
|||
import RecipeRating from "./RecipeRating.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage },
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
ratingColor: {
|
||||
type: String,
|
||||
default: "secondary",
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "abc123",
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
recipeId: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
imageHeight: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
},
|
||||
emits: ["click", "delete"],
|
||||
setup(props) {
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
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,
|
||||
showRecipeContent,
|
||||
cursor,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string | null;
|
||||
rating?: number;
|
||||
ratingColor?: string;
|
||||
image?: string;
|
||||
tags?: Array<any>;
|
||||
recipeId: string;
|
||||
imageHeight?: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
description: null,
|
||||
rating: 0,
|
||||
ratingColor: "secondary",
|
||||
image: "abc123",
|
||||
tags: () => [],
|
||||
imageHeight: 200,
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
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");
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
@ -195,6 +167,7 @@ export default defineNuxtComponent({
|
|||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 8;
|
||||
line-clamp: 8;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -28,84 +28,60 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
<script setup lang="ts">
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
tiny: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
large: {
|
||||
type: Boolean,
|
||||
default: null,
|
||||
},
|
||||
iconSize: {
|
||||
type: [Number, String],
|
||||
default: 100,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
imageVersion: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
type: [Number, String],
|
||||
default: "100%",
|
||||
},
|
||||
},
|
||||
emits: ["click"],
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
|
||||
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
|
||||
|
||||
const fallBackImage = ref(false);
|
||||
const imageSize = computed(() => {
|
||||
if (props.tiny) return "tiny";
|
||||
if (props.small) return "small";
|
||||
if (props.large) return "large";
|
||||
return "large";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.recipeId,
|
||||
() => {
|
||||
fallBackImage.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
function getImage(recipeId: string) {
|
||||
switch (imageSize.value) {
|
||||
case "tiny":
|
||||
return recipeTinyImage(recipeId, props.imageVersion);
|
||||
case "small":
|
||||
return recipeSmallImage(recipeId, props.imageVersion);
|
||||
case "large":
|
||||
return recipeImage(recipeId, props.imageVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
api,
|
||||
fallBackImage,
|
||||
imageSize,
|
||||
getImage,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
tiny?: boolean | null;
|
||||
small?: boolean | null;
|
||||
large?: boolean | null;
|
||||
iconSize?: number | string;
|
||||
slug?: string | null;
|
||||
recipeId: string;
|
||||
imageVersion?: string | null;
|
||||
height?: number | string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tiny: null,
|
||||
small: null,
|
||||
large: null,
|
||||
iconSize: 100,
|
||||
slug: null,
|
||||
imageVersion: null,
|
||||
height: "100%",
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const { recipeImage, recipeSmallImage, recipeTinyImage } = useStaticRoutes();
|
||||
|
||||
const fallBackImage = ref(false);
|
||||
const imageSize = computed(() => {
|
||||
if (props.tiny) return "tiny";
|
||||
if (props.small) return "small";
|
||||
if (props.large) return "large";
|
||||
return "large";
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.recipeId,
|
||||
() => {
|
||||
fallBackImage.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
function getImage(recipeId: string) {
|
||||
switch (imageSize.value) {
|
||||
case "tiny":
|
||||
return recipeTinyImage(recipeId, props.imageVersion);
|
||||
case "small":
|
||||
return recipeSmallImage(recipeId, props.imageVersion);
|
||||
case "large":
|
||||
return recipeImage(recipeId, props.imageVersion);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -126,7 +126,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeCardImage from "./RecipeCardImage.vue";
|
||||
|
@ -134,82 +134,44 @@ import RecipeRating from "./RecipeRating.vue";
|
|||
import RecipeChips from "./RecipeChips.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeFavoriteBadge,
|
||||
RecipeContextMenu,
|
||||
RecipeRating,
|
||||
RecipeCardImage,
|
||||
RecipeChips,
|
||||
},
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
rating: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "abc123",
|
||||
},
|
||||
tags: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
vertical: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isFlat: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
height: {
|
||||
type: [Number],
|
||||
default: 150,
|
||||
},
|
||||
disableHighlight: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["selected", "delete"],
|
||||
setup(props) {
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
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,
|
||||
showRecipeContent,
|
||||
cursor,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
rating?: number;
|
||||
image?: string;
|
||||
tags?: Array<any>;
|
||||
recipeId: string;
|
||||
vertical?: boolean;
|
||||
isFlat?: boolean;
|
||||
height?: number;
|
||||
disableHighlight?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rating: 0,
|
||||
image: "abc123",
|
||||
tags: () => [],
|
||||
vertical: false,
|
||||
isFlat: false,
|
||||
height: 150,
|
||||
disableHighlight: false,
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
selected: [];
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
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");
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -36,11 +36,11 @@
|
|||
offset-y
|
||||
start
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
variant="text"
|
||||
:icon="$vuetify.display.xs"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
:loading="sortLoading"
|
||||
>
|
||||
<v-icon :start="!$vuetify.display.xs">
|
||||
|
@ -162,7 +162,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useThrottleFn } from "@vueuse/core";
|
||||
import RecipeCard from "./RecipeCard.vue";
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
|
@ -175,273 +175,243 @@ import type { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
|
|||
const REPLACE_RECIPES_EVENT = "replaceRecipes";
|
||||
const APPEND_RECIPES_EVENT = "appendRecipes";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCard,
|
||||
RecipeCardMobile,
|
||||
},
|
||||
props: {
|
||||
disableToolbar: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableSort: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
singleColumn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipes: {
|
||||
type: Array as () => Recipe[],
|
||||
default: () => [],
|
||||
},
|
||||
query: {
|
||||
type: Object as () => RecipeSearchQuery,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const preferences = useUserSortPreferences();
|
||||
interface Props {
|
||||
disableToolbar?: boolean;
|
||||
disableSort?: boolean;
|
||||
icon?: string | null;
|
||||
title?: string | null;
|
||||
singleColumn?: boolean;
|
||||
recipes?: Recipe[];
|
||||
query?: RecipeSearchQuery | null;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disableToolbar: false,
|
||||
disableSort: false,
|
||||
icon: null,
|
||||
title: null,
|
||||
singleColumn: false,
|
||||
recipes: () => [],
|
||||
query: null,
|
||||
});
|
||||
|
||||
const EVENTS = {
|
||||
az: "az",
|
||||
rating: "rating",
|
||||
created: "created",
|
||||
updated: "updated",
|
||||
lastMade: "lastMade",
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
const emit = defineEmits<{
|
||||
replaceRecipes: [recipes: Recipe[]];
|
||||
appendRecipes: [recipes: Recipe[]];
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const preferences = useUserSortPreferences();
|
||||
|
||||
const displayTitleIcon = computed(() => {
|
||||
return props.icon || $globals.icons.tags;
|
||||
});
|
||||
const EVENTS = {
|
||||
az: "az",
|
||||
rating: "rating",
|
||||
created: "created",
|
||||
updated: "updated",
|
||||
lastMade: "lastMade",
|
||||
shuffle: "shuffle",
|
||||
};
|
||||
|
||||
const state = reactive({
|
||||
sortLoading: false,
|
||||
});
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const useMobileCards = computed(() => {
|
||||
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const displayTitleIcon = computed(() => {
|
||||
return props.icon || $globals.icons.tags;
|
||||
});
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
const hasMore = ref(true);
|
||||
const ready = ref(false);
|
||||
const loading = ref(false);
|
||||
const sortLoading = ref(false);
|
||||
|
||||
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const queryFilter = computed(() => {
|
||||
return props.query.queryFilter || null;
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
const hasMore = ref(true);
|
||||
const ready = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
|
||||
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value);
|
||||
const router = useRouter();
|
||||
|
||||
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
||||
const queryFilter = computed(() => {
|
||||
return props.query?.queryFilter || null;
|
||||
|
||||
// if (props.query.queryFilter && orderByFilter) {
|
||||
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
|
||||
// } else if (props.query.queryFilter) {
|
||||
// return props.query.queryFilter;
|
||||
// } else {
|
||||
// return orderByFilter;
|
||||
// }
|
||||
});
|
||||
// TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
|
||||
|
||||
async function fetchRecipes(pageCount = 1) {
|
||||
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
||||
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
||||
return await fetchMore(
|
||||
page.value,
|
||||
perPage * pageCount,
|
||||
props.query?.orderBy || preferences.value.orderBy,
|
||||
orderDir,
|
||||
orderByNullPosition,
|
||||
props.query,
|
||||
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
||||
queryFilter.value,
|
||||
);
|
||||
}
|
||||
// const orderBy = props.query?.orderBy || preferences.value.orderBy;
|
||||
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
|
||||
|
||||
onMounted(async () => {
|
||||
// if (props.query.queryFilter && orderByFilter) {
|
||||
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
|
||||
// } else if (props.query.queryFilter) {
|
||||
// return props.query.queryFilter;
|
||||
// } else {
|
||||
// return orderByFilter;
|
||||
// }
|
||||
});
|
||||
|
||||
async function fetchRecipes(pageCount = 1) {
|
||||
const orderDir = props.query?.orderDirection || preferences.value.orderDirection;
|
||||
const orderByNullPosition = props.query?.orderByNullPosition || orderDir === "asc" ? "first" : "last";
|
||||
return await fetchMore(
|
||||
page.value,
|
||||
perPage * pageCount,
|
||||
props.query?.orderBy || preferences.value.orderBy,
|
||||
orderDir,
|
||||
orderByNullPosition,
|
||||
props.query,
|
||||
// we use a computed queryFilter to filter out recipes that have a null value for the property we're sorting by
|
||||
queryFilter.value,
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
let lastQuery: string | undefined = JSON.stringify(props.query);
|
||||
watch(
|
||||
() => props.query,
|
||||
async (newValue: RecipeSearchQuery | undefined | null) => {
|
||||
const newValueString = JSON.stringify(newValue);
|
||||
if (lastQuery !== newValueString) {
|
||||
lastQuery = newValueString;
|
||||
ready.value = false;
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
let lastQuery: string | undefined = JSON.stringify(props.query);
|
||||
watch(
|
||||
() => props.query,
|
||||
async (newValue: RecipeSearchQuery | undefined) => {
|
||||
const newValueString = JSON.stringify(newValue);
|
||||
if (lastQuery !== newValueString) {
|
||||
lastQuery = newValueString;
|
||||
ready.value = false;
|
||||
await initRecipes();
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function initRecipes() {
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
// we double-up the first call to avoid a bug with large screens that render
|
||||
// the entire first page without scrolling, preventing additional loading
|
||||
const newRecipes = await fetchRecipes(page.value + 1);
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
|
||||
// since we doubled the first call, we also need to advance the page
|
||||
page.value = page.value + 1;
|
||||
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
async function sortRecipes(sortType: string) {
|
||||
if (state.sortLoading || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setter(
|
||||
orderBy: string,
|
||||
ascIcon: string,
|
||||
descIcon: string,
|
||||
defaultOrderDirection = "asc",
|
||||
filterNull = false,
|
||||
) {
|
||||
if (preferences.value.orderBy !== orderBy) {
|
||||
preferences.value.orderBy = orderBy;
|
||||
preferences.value.orderDirection = defaultOrderDirection;
|
||||
preferences.value.filterNull = filterNull;
|
||||
}
|
||||
else {
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
}
|
||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||
}
|
||||
|
||||
switch (sortType) {
|
||||
case EVENTS.az:
|
||||
setter(
|
||||
"name",
|
||||
$globals.icons.sortAlphabeticalAscending,
|
||||
$globals.icons.sortAlphabeticalDescending,
|
||||
"asc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.rating:
|
||||
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
|
||||
break;
|
||||
case EVENTS.created:
|
||||
setter(
|
||||
"created_at",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.updated:
|
||||
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
|
||||
break;
|
||||
case EVENTS.lastMade:
|
||||
setter(
|
||||
"last_made",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
true,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown Event", sortType);
|
||||
return;
|
||||
}
|
||||
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
state.sortLoading = true;
|
||||
loading.value = true;
|
||||
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
context.emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
|
||||
state.sortLoading = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function navigateRandom() {
|
||||
const recipe = await getRandom(props.query, queryFilter.value);
|
||||
if (!recipe?.slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
|
||||
}
|
||||
|
||||
function toggleMobileCards() {
|
||||
preferences.value.useMobileCards = !preferences.value.useMobileCards;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
displayTitleIcon,
|
||||
EVENTS,
|
||||
infiniteScroll,
|
||||
ready,
|
||||
loading,
|
||||
navigateRandom,
|
||||
preferences,
|
||||
sortRecipes,
|
||||
toggleMobileCards,
|
||||
useMobileCards,
|
||||
};
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
async function initRecipes() {
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
// we double-up the first call to avoid a bug with large screens that render
|
||||
// the entire first page without scrolling, preventing additional loading
|
||||
const newRecipes = await fetchRecipes(page.value + 1);
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
|
||||
// since we doubled the first call, we also need to advance the page
|
||||
page.value = page.value + 1;
|
||||
|
||||
emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
page.value = page.value + 1;
|
||||
|
||||
const newRecipes = await fetchRecipes();
|
||||
if (newRecipes.length < perPage) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
if (newRecipes.length) {
|
||||
emit(APPEND_RECIPES_EVENT, newRecipes);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
}, 500);
|
||||
|
||||
async function sortRecipes(sortType: string) {
|
||||
if (sortLoading.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setter(
|
||||
orderBy: string,
|
||||
ascIcon: string,
|
||||
descIcon: string,
|
||||
defaultOrderDirection = "asc",
|
||||
filterNull = false,
|
||||
) {
|
||||
if (preferences.value.orderBy !== orderBy) {
|
||||
preferences.value.orderBy = orderBy;
|
||||
preferences.value.orderDirection = defaultOrderDirection;
|
||||
preferences.value.filterNull = filterNull;
|
||||
}
|
||||
else {
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
}
|
||||
preferences.value.sortIcon = preferences.value.orderDirection === "asc" ? ascIcon : descIcon;
|
||||
}
|
||||
|
||||
switch (sortType) {
|
||||
case EVENTS.az:
|
||||
setter(
|
||||
"name",
|
||||
$globals.icons.sortAlphabeticalAscending,
|
||||
$globals.icons.sortAlphabeticalDescending,
|
||||
"asc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.rating:
|
||||
setter("rating", $globals.icons.sortAscending, $globals.icons.sortDescending, "desc", true);
|
||||
break;
|
||||
case EVENTS.created:
|
||||
setter(
|
||||
"created_at",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
false,
|
||||
);
|
||||
break;
|
||||
case EVENTS.updated:
|
||||
setter("updated_at", $globals.icons.sortClockAscending, $globals.icons.sortClockDescending, "desc", false);
|
||||
break;
|
||||
case EVENTS.lastMade:
|
||||
setter(
|
||||
"last_made",
|
||||
$globals.icons.sortCalendarAscending,
|
||||
$globals.icons.sortCalendarDescending,
|
||||
"desc",
|
||||
true,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.log("Unknown Event", sortType);
|
||||
return;
|
||||
}
|
||||
|
||||
// reset pagination
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
|
||||
sortLoading.value = true;
|
||||
loading.value = true;
|
||||
|
||||
// fetch new recipes
|
||||
const newRecipes = await fetchRecipes();
|
||||
emit(REPLACE_RECIPES_EVENT, newRecipes);
|
||||
|
||||
sortLoading.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
async function navigateRandom() {
|
||||
const recipe = await getRandom(props.query, queryFilter.value);
|
||||
if (!recipe?.slug) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/g/${groupSlug.value}/r/${recipe.slug}`);
|
||||
}
|
||||
|
||||
function toggleMobileCards() {
|
||||
preferences.value.useMobileCards = !preferences.value.useMobileCards;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -23,66 +23,38 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
truncate: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
items: {
|
||||
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[],
|
||||
default: () => [],
|
||||
},
|
||||
title: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
urlPrefix: {
|
||||
type: String as () => UrlPrefixParam,
|
||||
default: "categories",
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 999,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["item-selected"],
|
||||
setup(props) {
|
||||
const $auth = useMealieAuth();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
const baseRecipeRoute = computed<string>(() => {
|
||||
return `/g/${groupSlug.value}`;
|
||||
});
|
||||
|
||||
function truncateText(text: string, length = 20, clamp = "...") {
|
||||
if (!props.truncate) return text;
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = text;
|
||||
const content = node.textContent || "";
|
||||
return content.length > length ? content.slice(0, length) + clamp : content;
|
||||
}
|
||||
|
||||
return {
|
||||
baseRecipeRoute,
|
||||
truncateText,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
truncate?: boolean;
|
||||
items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
|
||||
title?: boolean;
|
||||
urlPrefix?: UrlPrefixParam;
|
||||
limit?: number;
|
||||
small?: boolean;
|
||||
maxWidth?: string | null;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
truncate: false,
|
||||
items: () => [],
|
||||
title: false,
|
||||
urlPrefix: "categories",
|
||||
limit: 999,
|
||||
small: false,
|
||||
maxWidth: null,
|
||||
});
|
||||
|
||||
defineEmits(["item-selected"]);
|
||||
function truncateText(text: string, length = 20, clamp = "...") {
|
||||
if (!props.truncate) return text;
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = text;
|
||||
const content = node.textContent || "";
|
||||
return content.length > length ? content.slice(0, length) + clamp : content;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
|
|
@ -55,12 +55,12 @@
|
|||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-model="newMealdateString"
|
||||
:label="$t('general.date')"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
|
@ -100,7 +100,7 @@
|
|||
:open-on-hover="$vuetify.display.mdAndUp"
|
||||
content-class="d-print-none"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
icon
|
||||
:variant="fab ? 'flat' : undefined"
|
||||
|
@ -108,7 +108,7 @@
|
|||
:size="fab ? 'small' : undefined"
|
||||
:color="fab ? 'info' : 'secondary'"
|
||||
:fab="fab"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
@click.prevent
|
||||
>
|
||||
<v-icon
|
||||
|
@ -150,7 +150,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
|
||||
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
|
||||
import RecipeDialogShare from "./RecipeDialogShare.vue";
|
||||
|
@ -186,363 +186,312 @@ export interface ContextMenuItem {
|
|||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeDialogAddToShoppingList,
|
||||
RecipeDialogPrintPreferences,
|
||||
RecipeDialogShare,
|
||||
},
|
||||
props: {
|
||||
useItems: {
|
||||
type: Object as () => ContextMenuIncludes,
|
||||
default: () => ({
|
||||
delete: true,
|
||||
edit: true,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: true,
|
||||
recipeActions: 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,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "primary",
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
menuIcon: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
name: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
default: undefined,
|
||||
},
|
||||
recipeId: {
|
||||
required: true,
|
||||
type: String,
|
||||
},
|
||||
recipeScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["delete"],
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
||||
const state = reactive({
|
||||
printPreferencesDialog: false,
|
||||
shareDialog: false,
|
||||
recipeDeleteDialog: false,
|
||||
mealplannerDialog: false,
|
||||
shoppingListDialog: false,
|
||||
recipeDuplicateDialog: false,
|
||||
recipeName: props.name,
|
||||
loading: false,
|
||||
menuItems: [] as ContextMenuItem[],
|
||||
newMealdate: new Date(),
|
||||
newMealType: "dinner" as PlanEntryType,
|
||||
pickerMenu: false,
|
||||
});
|
||||
|
||||
const newMealdateString = computed(() => {
|
||||
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
|
||||
const year = state.newMealdate.getFullYear();
|
||||
const month = String(state.newMealdate.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(state.newMealdate.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Setup
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
isPublic: false,
|
||||
},
|
||||
delete: {
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: undefined,
|
||||
event: "delete",
|
||||
isPublic: false,
|
||||
},
|
||||
download: {
|
||||
title: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: undefined,
|
||||
event: "download",
|
||||
isPublic: false,
|
||||
},
|
||||
duplicate: {
|
||||
title: i18n.t("general.duplicate"),
|
||||
icon: $globals.icons.duplicate,
|
||||
color: undefined,
|
||||
event: "duplicate",
|
||||
isPublic: false,
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.t("recipe.add-to-plan"),
|
||||
icon: $globals.icons.calendar,
|
||||
color: undefined,
|
||||
event: "mealplanner",
|
||||
isPublic: false,
|
||||
},
|
||||
shoppingList: {
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
print: {
|
||||
title: i18n.t("general.print"),
|
||||
icon: $globals.icons.printer,
|
||||
color: undefined,
|
||||
event: "print",
|
||||
isPublic: true,
|
||||
},
|
||||
printPreferences: {
|
||||
title: i18n.t("general.print-preferences"),
|
||||
icon: $globals.icons.printerSettings,
|
||||
color: undefined,
|
||||
event: "printPreferences",
|
||||
isPublic: true,
|
||||
},
|
||||
share: {
|
||||
title: i18n.t("general.share"),
|
||||
icon: $globals.icons.shareVariant,
|
||||
color: undefined,
|
||||
event: "share",
|
||||
isPublic: false,
|
||||
},
|
||||
};
|
||||
|
||||
// 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 shoppingLists = ref<ShoppingListSummary[]>();
|
||||
const recipeRef = ref<Recipe | undefined>(props.recipe);
|
||||
const recipeRefWithScale = computed(() =>
|
||||
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
|
||||
);
|
||||
const isAdminAndNotOwner = computed(() => {
|
||||
return (
|
||||
$auth.user.value?.admin
|
||||
&& $auth.user.value?.id !== recipeRef.value?.userId
|
||||
);
|
||||
});
|
||||
const canDelete = computed(() => {
|
||||
const user = $auth.user.value;
|
||||
const recipe = recipeRef.value;
|
||||
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||
});
|
||||
|
||||
// Get Default Menu Items Specified in Props
|
||||
for (const [key, value] of Object.entries(props.useItems)) {
|
||||
if (!value) continue;
|
||||
|
||||
// Skip delete if not allowed
|
||||
if (key === "delete" && !canDelete.value) continue;
|
||||
|
||||
const item = defaultItems[key];
|
||||
if (item && (item.isPublic || isOwnGroup.value)) {
|
||||
state.menuItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function getShoppingLists() {
|
||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||
if (data) {
|
||||
shoppingLists.value = data.items ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecipe() {
|
||||
const { data } = await api.recipes.getOne(props.slug);
|
||||
if (data) {
|
||||
recipeRef.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
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.t("events.message-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const { data } = await api.recipes.deleteOne(props.slug);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}`);
|
||||
}
|
||||
context.emit("delete", props.slug);
|
||||
}
|
||||
|
||||
const download = useDownloader();
|
||||
|
||||
async function handleDownloadEvent() {
|
||||
const { data } = await api.recipes.getZipToken(props.slug);
|
||||
|
||||
if (data) {
|
||||
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addRecipeToPlan() {
|
||||
const { response } = await api.mealplans.createOne({
|
||||
date: newMealdateString.value,
|
||||
entryType: state.newMealType,
|
||||
title: "",
|
||||
text: "",
|
||||
recipeId: props.recipeId,
|
||||
});
|
||||
|
||||
if (response?.status === 201) {
|
||||
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateRecipe() {
|
||||
const { data } = await api.recipes.duplicateOne(props.slug, state.recipeName);
|
||||
if (data && data.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
},
|
||||
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
|
||||
download: handleDownloadEvent,
|
||||
duplicate: () => {
|
||||
state.recipeDuplicateDialog = true;
|
||||
},
|
||||
mealplanner: () => {
|
||||
state.mealplannerDialog = true;
|
||||
},
|
||||
printPreferences: async () => {
|
||||
if (!recipeRef.value) {
|
||||
await refreshRecipe();
|
||||
}
|
||||
state.printPreferencesDialog = true;
|
||||
},
|
||||
shoppingList: () => {
|
||||
const promises: Promise<void>[] = [getShoppingLists()];
|
||||
if (!recipeRef.value) {
|
||||
promises.push(refreshRecipe());
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(() => {
|
||||
state.shoppingListDialog = true;
|
||||
});
|
||||
},
|
||||
share: () => {
|
||||
state.shareDialog = 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;
|
||||
}
|
||||
|
||||
const planTypeOptions = usePlanTypeOptions();
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
newMealdateString,
|
||||
recipeRef,
|
||||
recipeRefWithScale,
|
||||
executeRecipeAction,
|
||||
recipeActions: groupRecipeActionsStore.recipeActions,
|
||||
shoppingLists,
|
||||
duplicateRecipe,
|
||||
contextMenuEventHandler,
|
||||
deleteRecipe,
|
||||
addRecipeToPlan,
|
||||
icon,
|
||||
planTypeOptions,
|
||||
firstDayOfWeek,
|
||||
isAdminAndNotOwner,
|
||||
canDelete,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
useItems?: ContextMenuIncludes;
|
||||
appendItems?: ContextMenuItem[];
|
||||
leadingItems?: ContextMenuItem[];
|
||||
menuTop?: boolean;
|
||||
fab?: boolean;
|
||||
color?: string;
|
||||
slug: string;
|
||||
menuIcon?: string | null;
|
||||
name: string;
|
||||
recipe?: Recipe;
|
||||
recipeId: string;
|
||||
recipeScale?: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
useItems: () => ({
|
||||
delete: true,
|
||||
edit: true,
|
||||
download: true,
|
||||
duplicate: false,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
printPreferences: true,
|
||||
share: true,
|
||||
recipeActions: true,
|
||||
}),
|
||||
appendItems: () => [],
|
||||
leadingItems: () => [],
|
||||
menuTop: true,
|
||||
fab: false,
|
||||
color: "primary",
|
||||
menuIcon: null,
|
||||
recipe: undefined,
|
||||
recipeScale: 1,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
[key: string]: any;
|
||||
delete: [slug: string];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
|
||||
const printPreferencesDialog = ref(false);
|
||||
const shareDialog = ref(false);
|
||||
const recipeDeleteDialog = ref(false);
|
||||
const mealplannerDialog = ref(false);
|
||||
const shoppingListDialog = ref(false);
|
||||
const recipeDuplicateDialog = ref(false);
|
||||
const recipeName = ref(props.name);
|
||||
const loading = ref(false);
|
||||
const menuItems = ref<ContextMenuItem[]>([]);
|
||||
const newMealdate = ref(new Date());
|
||||
const newMealType = ref<PlanEntryType>("dinner");
|
||||
const pickerMenu = ref(false);
|
||||
|
||||
const newMealdateString = computed(() => {
|
||||
// Format the date to YYYY-MM-DD in the same timezone as newMealdate
|
||||
const year = newMealdate.value.getFullYear();
|
||||
const month = String(newMealdate.value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(newMealdate.value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { $globals } = useNuxtApp();
|
||||
const { household } = useHouseholdSelf();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Setup
|
||||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.t("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
isPublic: false,
|
||||
},
|
||||
delete: {
|
||||
title: i18n.t("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: undefined,
|
||||
event: "delete",
|
||||
isPublic: false,
|
||||
},
|
||||
download: {
|
||||
title: i18n.t("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: undefined,
|
||||
event: "download",
|
||||
isPublic: false,
|
||||
},
|
||||
duplicate: {
|
||||
title: i18n.t("general.duplicate"),
|
||||
icon: $globals.icons.duplicate,
|
||||
color: undefined,
|
||||
event: "duplicate",
|
||||
isPublic: false,
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.t("recipe.add-to-plan"),
|
||||
icon: $globals.icons.calendar,
|
||||
color: undefined,
|
||||
event: "mealplanner",
|
||||
isPublic: false,
|
||||
},
|
||||
shoppingList: {
|
||||
title: i18n.t("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
isPublic: false,
|
||||
},
|
||||
print: {
|
||||
title: i18n.t("general.print"),
|
||||
icon: $globals.icons.printer,
|
||||
color: undefined,
|
||||
event: "print",
|
||||
isPublic: true,
|
||||
},
|
||||
printPreferences: {
|
||||
title: i18n.t("general.print-preferences"),
|
||||
icon: $globals.icons.printerSettings,
|
||||
color: undefined,
|
||||
event: "printPreferences",
|
||||
isPublic: true,
|
||||
},
|
||||
share: {
|
||||
title: i18n.t("general.share"),
|
||||
icon: $globals.icons.shareVariant,
|
||||
color: undefined,
|
||||
event: "share",
|
||||
isPublic: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Add leading and Appending Items
|
||||
menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
|
||||
|
||||
const icon = props.menuIcon || $globals.icons.dotsVertical;
|
||||
|
||||
// ===========================================================================
|
||||
// Context Menu Event Handler
|
||||
|
||||
const shoppingLists = ref<ShoppingListSummary[]>();
|
||||
const recipeRef = ref<Recipe | undefined>(props.recipe);
|
||||
const recipeRefWithScale = computed(() =>
|
||||
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
|
||||
);
|
||||
const isAdminAndNotOwner = computed(() => {
|
||||
return (
|
||||
$auth.user.value?.admin
|
||||
&& $auth.user.value?.id !== recipeRef.value?.userId
|
||||
);
|
||||
});
|
||||
const canDelete = computed(() => {
|
||||
const user = $auth.user.value;
|
||||
const recipe = recipeRef.value;
|
||||
return user && recipe && (user.admin || user.id === recipe.userId);
|
||||
});
|
||||
|
||||
// Get Default Menu Items Specified in Props
|
||||
for (const [key, value] of Object.entries(props.useItems)) {
|
||||
if (!value) continue;
|
||||
|
||||
// Skip delete if not allowed
|
||||
if (key === "delete" && !canDelete.value) continue;
|
||||
|
||||
const item = defaultItems[key];
|
||||
if (item && (item.isPublic || isOwnGroup.value)) {
|
||||
menuItems.value.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function getShoppingLists() {
|
||||
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
|
||||
if (data) {
|
||||
shoppingLists.value = data.items ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecipe() {
|
||||
const { data } = await api.recipes.getOne(props.slug);
|
||||
if (data) {
|
||||
recipeRef.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
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.t("events.message-sent"));
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("events.something-went-wrong"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecipe() {
|
||||
const { data } = await api.recipes.deleteOne(props.slug);
|
||||
if (data?.slug) {
|
||||
router.push(`/g/${groupSlug.value}`);
|
||||
}
|
||||
emit("delete", props.slug);
|
||||
}
|
||||
|
||||
const download = useDownloader();
|
||||
|
||||
async function handleDownloadEvent() {
|
||||
const { data } = await api.recipes.getZipToken(props.slug);
|
||||
|
||||
if (data) {
|
||||
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addRecipeToPlan() {
|
||||
const { response } = await api.mealplans.createOne({
|
||||
date: newMealdateString.value,
|
||||
entryType: newMealType.value,
|
||||
title: "",
|
||||
text: "",
|
||||
recipeId: props.recipeId,
|
||||
});
|
||||
|
||||
if (response?.status === 201) {
|
||||
alert.success(i18n.t("recipe.recipe-added-to-mealplan") as string);
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function duplicateRecipe() {
|
||||
const { data } = await api.recipes.duplicateOne(props.slug, recipeName.value);
|
||||
if (data && data.slug) {
|
||||
router.push(`/g/${groupSlug.value}/r/${data.slug}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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: () => {
|
||||
recipeDeleteDialog.value = true;
|
||||
},
|
||||
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
|
||||
download: handleDownloadEvent,
|
||||
duplicate: () => {
|
||||
recipeDuplicateDialog.value = true;
|
||||
},
|
||||
mealplanner: () => {
|
||||
mealplannerDialog.value = true;
|
||||
},
|
||||
printPreferences: async () => {
|
||||
if (!recipeRef.value) {
|
||||
await refreshRecipe();
|
||||
}
|
||||
printPreferencesDialog.value = true;
|
||||
},
|
||||
shoppingList: () => {
|
||||
const promises: Promise<void>[] = [getShoppingLists()];
|
||||
if (!recipeRef.value) {
|
||||
promises.push(refreshRecipe());
|
||||
}
|
||||
|
||||
Promise.allSettled(promises).then(() => {
|
||||
shoppingListDialog.value = true;
|
||||
});
|
||||
},
|
||||
share: () => {
|
||||
shareDialog.value = true;
|
||||
},
|
||||
};
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
const handler = eventHandlers[eventKey];
|
||||
|
||||
if (handler && typeof handler === "function") {
|
||||
handler();
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
emit(eventKey);
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const planTypeOptions = usePlanTypeOptions();
|
||||
const recipeActions = groupRecipeActionsStore.recipeActions;
|
||||
</script>
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { validators } from "~/composables/use-validators";
|
||||
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
|
||||
|
@ -42,86 +42,66 @@ export interface GenericAlias {
|
|||
name: string;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
data: {
|
||||
type: Object as () => IngredientFood | IngredientUnit,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["submit", "update:modelValue", "cancel"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
interface Props {
|
||||
data: IngredientFood | IngredientUnit;
|
||||
}
|
||||
|
||||
function createAlias() {
|
||||
aliases.value.push({
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
function deleteAlias(index: number) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
submit: [aliases: GenericAlias[]];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
|
||||
function initAliases() {
|
||||
aliases.value = [...props.data.aliases || []];
|
||||
if (!aliases.value.length) {
|
||||
createAlias();
|
||||
}
|
||||
}
|
||||
// V-Model Support
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
function createAlias() {
|
||||
aliases.value.push({
|
||||
name: "",
|
||||
});
|
||||
}
|
||||
|
||||
function deleteAlias(index: number) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
|
||||
const aliases = ref<GenericAlias[]>(props.data.aliases || []);
|
||||
function initAliases() {
|
||||
aliases.value = [...props.data.aliases || []];
|
||||
if (!aliases.value.length) {
|
||||
createAlias();
|
||||
}
|
||||
}
|
||||
|
||||
initAliases();
|
||||
whenever(
|
||||
() => dialog.value,
|
||||
() => {
|
||||
initAliases();
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initAliases();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
const keepAliases: GenericAlias[] = [];
|
||||
aliases.value.forEach((alias) => {
|
||||
if (
|
||||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
|| alias.name === props.data.abbreviation
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
});
|
||||
|
||||
aliases.value = keepAliases;
|
||||
context.emit("submit", keepAliases);
|
||||
function saveAliases() {
|
||||
const seenAliasNames: string[] = [];
|
||||
const keepAliases: GenericAlias[] = [];
|
||||
aliases.value.forEach((alias) => {
|
||||
if (
|
||||
!alias.name
|
||||
|| alias.name === props.data.name
|
||||
|| alias.name === props.data.pluralName
|
||||
|| alias.name === props.data.abbreviation
|
||||
|| alias.name === props.data.pluralAbbreviation
|
||||
|| seenAliasNames.includes(alias.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
aliases,
|
||||
createAlias,
|
||||
dialog,
|
||||
deleteAlias,
|
||||
saveAliases,
|
||||
validators,
|
||||
};
|
||||
},
|
||||
});
|
||||
keepAliases.push(alias);
|
||||
seenAliasNames.push(alias.name);
|
||||
});
|
||||
|
||||
aliases.value = keepAliases;
|
||||
emit("submit", keepAliases);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -62,7 +62,7 @@
|
|||
</v-data-table>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import UserAvatar from "../User/UserAvatar.vue";
|
||||
import RecipeChip from "./RecipeChips.vue";
|
||||
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
@ -70,8 +70,6 @@ import { useUserApi } from "~/composables/api";
|
|||
import type { UserSummary } from "~/lib/api/types/user";
|
||||
import type { RecipeTag } from "~/lib/api/types/household";
|
||||
|
||||
const INPUT_EVENT = "update:modelValue";
|
||||
|
||||
interface ShowHeaders {
|
||||
id: boolean;
|
||||
owner: boolean;
|
||||
|
@ -84,140 +82,114 @@ interface ShowHeaders {
|
|||
dateAdded: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeChip, UserAvatar },
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Array as PropType<Recipe[]>,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
recipes: {
|
||||
type: Array as () => Recipe[],
|
||||
default: () => [],
|
||||
},
|
||||
showHeaders: {
|
||||
type: Object as () => ShowHeaders,
|
||||
required: false,
|
||||
default: () => {
|
||||
return {
|
||||
id: true,
|
||||
owner: false,
|
||||
tags: true,
|
||||
categories: true,
|
||||
recipeServings: true,
|
||||
recipeYieldQuantity: true,
|
||||
recipeYield: true,
|
||||
dateAdded: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ["click", "update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
const selected = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => context.emit(INPUT_EVENT, value),
|
||||
});
|
||||
|
||||
// Initialize sort state with default sorting by dateAdded descending
|
||||
const sortBy = ref([{ key: "dateAdded", order: "desc" }]);
|
||||
|
||||
const headers = computed(() => {
|
||||
const hdrs: Array<{ title: string; value: string; align?: string; sortable?: boolean }> = [];
|
||||
|
||||
if (props.showHeaders.id) {
|
||||
hdrs.push({ title: i18n.t("general.id"), value: "id" });
|
||||
}
|
||||
if (props.showHeaders.owner) {
|
||||
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
||||
}
|
||||
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
||||
if (props.showHeaders.categories) {
|
||||
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||
}
|
||||
|
||||
if (props.showHeaders.tags) {
|
||||
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeServings) {
|
||||
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYieldQuantity) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||
}
|
||||
|
||||
return hdrs;
|
||||
});
|
||||
|
||||
function formatDate(date: string) {
|
||||
try {
|
||||
return i18n.d(Date.parse(date), "medium");
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Group Members
|
||||
const api = useUserApi();
|
||||
const members = ref<UserSummary[]>([]);
|
||||
|
||||
async function refreshMembers() {
|
||||
const { data } = await api.groups.fetchMembers();
|
||||
if (data) {
|
||||
members.value = data.items;
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||
if (!groupSlug || !item.id) {
|
||||
return;
|
||||
}
|
||||
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshMembers();
|
||||
});
|
||||
|
||||
function getMember(id: string) {
|
||||
if (members.value[0]) {
|
||||
return members.value.find(m => m.id === id)?.fullName;
|
||||
}
|
||||
|
||||
return i18n.t("general.none");
|
||||
}
|
||||
|
||||
return {
|
||||
selected,
|
||||
sortBy,
|
||||
groupSlug,
|
||||
headers,
|
||||
formatDate,
|
||||
members,
|
||||
getMember,
|
||||
filterItems,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
loading?: boolean;
|
||||
recipes?: Recipe[];
|
||||
showHeaders?: ShowHeaders;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
recipes: () => [],
|
||||
showHeaders: () => ({
|
||||
id: true,
|
||||
owner: false,
|
||||
tags: true,
|
||||
categories: true,
|
||||
tools: true,
|
||||
recipeServings: true,
|
||||
recipeYieldQuantity: true,
|
||||
recipeYield: true,
|
||||
dateAdded: true,
|
||||
}),
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
click: [];
|
||||
}>();
|
||||
|
||||
const selected = defineModel<Recipe[]>({ default: () => [] });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const groupSlug = $auth.user.value?.groupSlug;
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize sort state with default sorting by dateAdded descending
|
||||
const sortBy = ref([{ key: "dateAdded", order: "desc" as const }]);
|
||||
|
||||
const headers = computed(() => {
|
||||
const hdrs: Array<{ title: string; value: string; align?: "center" | "start" | "end"; sortable?: boolean }> = [];
|
||||
|
||||
if (props.showHeaders.id) {
|
||||
hdrs.push({ title: i18n.t("general.id"), value: "id" });
|
||||
}
|
||||
if (props.showHeaders.owner) {
|
||||
hdrs.push({ title: i18n.t("general.owner"), value: "userId", align: "center", sortable: true });
|
||||
}
|
||||
hdrs.push({ title: i18n.t("general.name"), value: "name", sortable: true });
|
||||
if (props.showHeaders.categories) {
|
||||
hdrs.push({ title: i18n.t("recipe.categories"), value: "recipeCategory", sortable: true });
|
||||
}
|
||||
|
||||
if (props.showHeaders.tags) {
|
||||
hdrs.push({ title: i18n.t("tag.tags"), value: "tags", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.tools) {
|
||||
hdrs.push({ title: i18n.t("tool.tools"), value: "tools", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeServings) {
|
||||
hdrs.push({ title: i18n.t("recipe.servings"), value: "recipeServings", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYieldQuantity) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield"), value: "recipeYieldQuantity", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.recipeYield) {
|
||||
hdrs.push({ title: i18n.t("recipe.yield-text"), value: "recipeYield", sortable: true });
|
||||
}
|
||||
if (props.showHeaders.dateAdded) {
|
||||
hdrs.push({ title: i18n.t("general.date-added"), value: "dateAdded", sortable: true });
|
||||
}
|
||||
|
||||
return hdrs;
|
||||
});
|
||||
|
||||
function formatDate(date: string) {
|
||||
try {
|
||||
return i18n.d(Date.parse(date), "medium");
|
||||
}
|
||||
catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ============
|
||||
// Group Members
|
||||
const api = useUserApi();
|
||||
const members = ref<UserSummary[]>([]);
|
||||
|
||||
async function refreshMembers() {
|
||||
const { data } = await api.groups.fetchMembers();
|
||||
if (data) {
|
||||
members.value = data.items;
|
||||
}
|
||||
}
|
||||
|
||||
function filterItems(item: RecipeTag | RecipeCategory | RecipeTool, itemType: string) {
|
||||
if (!groupSlug || !item.id) {
|
||||
return;
|
||||
}
|
||||
router.push(`/g/${groupSlug}?${itemType}=${item.id}`);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshMembers();
|
||||
});
|
||||
|
||||
function getMember(id: string) {
|
||||
if (members.value[0]) {
|
||||
return members.value.find(m => m.id === id)?.fullName;
|
||||
}
|
||||
|
||||
return i18n.t("general.none");
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -51,7 +51,7 @@
|
|||
<BaseDialog
|
||||
v-if="shoppingListIngredientDialog"
|
||||
v-model="dialog"
|
||||
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')"
|
||||
:title="selectedShoppingList?.name || $t('recipe.add-to-list')"
|
||||
:icon="$globals.icons.cartCheck"
|
||||
width="70%"
|
||||
:submit-text="$t('recipe.add-to-list')"
|
||||
|
@ -137,7 +137,7 @@
|
|||
color="secondary"
|
||||
density="compact"
|
||||
/>
|
||||
<div :key="ingredientData.ingredient.quantity">
|
||||
<div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`">
|
||||
<RecipeIngredientListItem
|
||||
:ingredient="ingredientData.ingredient"
|
||||
:disable-amount="ingredientData.disableAmount"
|
||||
|
@ -172,7 +172,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { toRefs } from "@vueuse/core";
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
@ -203,240 +203,215 @@ export interface ShoppingListRecipeIngredientSection {
|
|||
ingredientSections: ShoppingListIngredientSection[];
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeIngredientListItem,
|
||||
interface Props {
|
||||
recipes?: RecipeWithScale[];
|
||||
shoppingLists?: ShoppingListSummary[];
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipes: undefined,
|
||||
shoppingLists: () => [],
|
||||
});
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
|
||||
const state = reactive({
|
||||
shoppingListDialog: true,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
|
||||
const { shoppingListDialog, shoppingListIngredientDialog, shoppingListShowAllToggled: _shoppingListShowAllToggled } = toRefs(state);
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
const shoppingListChoices = computed(() => {
|
||||
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||
});
|
||||
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
}
|
||||
else {
|
||||
ready.value = true;
|
||||
}
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipes: {
|
||||
type: Array as () => RecipeWithScale[],
|
||||
default: undefined,
|
||||
},
|
||||
shoppingLists: {
|
||||
type: Array as () => ShoppingListSummary[],
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const api = useUserApi();
|
||||
const preferences = useShoppingListPreferences();
|
||||
const ready = ref(false);
|
||||
);
|
||||
|
||||
// v-model support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
initState();
|
||||
},
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
initState();
|
||||
}
|
||||
});
|
||||
|
||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
||||
for (const recipe of recipes) {
|
||||
if (!recipe.slug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (recipeSectionMap.has(recipe.slug)) {
|
||||
const existingSection = recipeSectionMap.get(recipe.slug);
|
||||
if (existingSection) {
|
||||
existingSection.recipeScale += recipe.scale;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipe.slug);
|
||||
if (!data?.recipeIngredient?.length) {
|
||||
continue;
|
||||
}
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
||||
return {
|
||||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
ingredient: ing,
|
||||
disableAmount: recipe.settings?.disableAmount || false,
|
||||
};
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
shoppingListDialog: true,
|
||||
shoppingListIngredientDialog: false,
|
||||
shoppingListShowAllToggled: false,
|
||||
});
|
||||
let currentTitle = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
|
||||
const userHousehold = computed(() => {
|
||||
return $auth.user.value?.householdSlug || "";
|
||||
});
|
||||
|
||||
const shoppingListChoices = computed(() => {
|
||||
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id);
|
||||
});
|
||||
|
||||
const recipeIngredientSections = ref<ShoppingListRecipeIngredientSection[]>([]);
|
||||
const selectedShoppingList = ref<ShoppingListSummary | null>(null);
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
if (shoppingListChoices.value.length === 1 && !state.shoppingListShowAllToggled) {
|
||||
selectedShoppingList.value = shoppingListChoices.value[0];
|
||||
openShoppingListIngredientDialog(selectedShoppingList.value);
|
||||
// If this is the first item in the section, create a new section
|
||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||
if (sections.length) {
|
||||
// Add the on-hand ingredients to the previous section
|
||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||
onHandIngs.length = 0;
|
||||
}
|
||||
else {
|
||||
ready.value = true;
|
||||
sections.push({
|
||||
sectionName: currentTitle,
|
||||
ingredients: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Store the on-hand ingredients for later
|
||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Add the ingredient to previous section
|
||||
sections[sections.length - 1].ingredients.push(ing);
|
||||
return sections;
|
||||
}, [] as ShoppingListIngredientSection[]);
|
||||
|
||||
// Add remaining on-hand ingredients to the previous section
|
||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||
|
||||
recipeSectionMap.set(recipe.slug, {
|
||||
recipeId: recipe.id,
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
});
|
||||
}
|
||||
|
||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||
}
|
||||
|
||||
function initState() {
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
selectedShoppingList.value = null;
|
||||
}
|
||||
|
||||
initState();
|
||||
|
||||
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
|
||||
if (!props.recipes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedShoppingList.value = list;
|
||||
await consolidateRecipesIntoSections(props.recipes);
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = true;
|
||||
}
|
||||
|
||||
function setShowAllToggled() {
|
||||
state.shoppingListShowAllToggled = true;
|
||||
}
|
||||
|
||||
function bulkCheckIngredients(value = true) {
|
||||
recipeIngredientSections.value.forEach((recipeSection) => {
|
||||
recipeSection.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
ing.checked = value;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function addRecipesToList() {
|
||||
if (!selectedShoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
|
||||
recipeIngredientSections.value.forEach((section) => {
|
||||
const ingredients: RecipeIngredient[] = [];
|
||||
section.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
if (ing.checked) {
|
||||
ingredients.push(ing.ingredient);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!ingredients.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
recipeData.push(
|
||||
{
|
||||
recipeId: section.recipeId,
|
||||
recipeIncrementQuantity: section.recipeScale,
|
||||
recipeIngredients: ingredients,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
|
||||
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>();
|
||||
for (const recipe of recipes) {
|
||||
if (!recipe.slug) {
|
||||
continue;
|
||||
}
|
||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||
// 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"));
|
||||
|
||||
if (recipeSectionMap.has(recipe.slug)) {
|
||||
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(recipe.id && recipe.name && recipe.recipeIngredient)) {
|
||||
const { data } = await api.recipes.getOne(recipe.slug);
|
||||
if (!data?.recipeIngredient?.length) {
|
||||
continue;
|
||||
}
|
||||
recipe.id = data.id || "";
|
||||
recipe.name = data.name || "";
|
||||
recipe.recipeIngredient = data.recipeIngredient;
|
||||
}
|
||||
else if (!recipe.recipeIngredient.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const shoppingListIngredients: ShoppingListIngredient[] = recipe.recipeIngredient.map((ing) => {
|
||||
const householdsWithFood = (ing.food?.householdsWithIngredientFood || []);
|
||||
return {
|
||||
checked: !householdsWithFood.includes(userHousehold.value),
|
||||
ingredient: ing,
|
||||
disableAmount: recipe.settings?.disableAmount || false,
|
||||
};
|
||||
});
|
||||
|
||||
let currentTitle = "";
|
||||
const onHandIngs: ShoppingListIngredient[] = [];
|
||||
const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
|
||||
if (ing.ingredient.title) {
|
||||
currentTitle = ing.ingredient.title;
|
||||
}
|
||||
|
||||
// If this is the first item in the section, create a new section
|
||||
if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
|
||||
if (sections.length) {
|
||||
// Add the on-hand ingredients to the previous section
|
||||
sections[sections.length - 1].ingredients.push(...onHandIngs);
|
||||
onHandIngs.length = 0;
|
||||
}
|
||||
sections.push({
|
||||
sectionName: currentTitle,
|
||||
ingredients: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Store the on-hand ingredients for later
|
||||
const householdsWithFood = (ing.ingredient.food?.householdsWithIngredientFood || []);
|
||||
if (householdsWithFood.includes(userHousehold.value)) {
|
||||
onHandIngs.push(ing);
|
||||
return sections;
|
||||
}
|
||||
|
||||
// Add the ingredient to previous section
|
||||
sections[sections.length - 1].ingredients.push(ing);
|
||||
return sections;
|
||||
}, [] as ShoppingListIngredientSection[]);
|
||||
|
||||
// Add remaining on-hand ingredients to the previous section
|
||||
shoppingListIngredientSections[shoppingListIngredientSections.length - 1].ingredients.push(...onHandIngs);
|
||||
|
||||
recipeSectionMap.set(recipe.slug, {
|
||||
recipeId: recipe.id,
|
||||
recipeName: recipe.name,
|
||||
recipeScale: recipe.scale,
|
||||
ingredientSections: shoppingListIngredientSections,
|
||||
});
|
||||
}
|
||||
|
||||
recipeIngredientSections.value = Array.from(recipeSectionMap.values());
|
||||
}
|
||||
|
||||
function initState() {
|
||||
state.shoppingListDialog = true;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
state.shoppingListShowAllToggled = false;
|
||||
recipeIngredientSections.value = [];
|
||||
selectedShoppingList.value = null;
|
||||
}
|
||||
|
||||
initState();
|
||||
|
||||
async function openShoppingListIngredientDialog(list: ShoppingListSummary) {
|
||||
if (!props.recipes?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedShoppingList.value = list;
|
||||
await consolidateRecipesIntoSections(props.recipes);
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = true;
|
||||
}
|
||||
|
||||
function setShowAllToggled() {
|
||||
state.shoppingListShowAllToggled = true;
|
||||
}
|
||||
|
||||
function bulkCheckIngredients(value = true) {
|
||||
recipeIngredientSections.value.forEach((recipeSection) => {
|
||||
recipeSection.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
ing.checked = value;
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function addRecipesToList() {
|
||||
if (!selectedShoppingList.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipeData: ShoppingListAddRecipeParamsBulk[] = [];
|
||||
recipeIngredientSections.value.forEach((section) => {
|
||||
const ingredients: RecipeIngredient[] = [];
|
||||
section.ingredientSections.forEach((ingSection) => {
|
||||
ingSection.ingredients.forEach((ing) => {
|
||||
if (ing.checked) {
|
||||
ingredients.push(ing.ingredient);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!ingredients.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
recipeData.push(
|
||||
{
|
||||
recipeId: section.recipeId,
|
||||
recipeIncrementQuantity: section.recipeScale,
|
||||
recipeIngredients: ingredients,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
|
||||
// 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;
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
dialog,
|
||||
preferences,
|
||||
ready,
|
||||
shoppingListChoices,
|
||||
...toRefs(state),
|
||||
addRecipesToList,
|
||||
bulkCheckIngredients,
|
||||
openShoppingListIngredientDialog,
|
||||
setShowAllToggled,
|
||||
recipeIngredientSections,
|
||||
selectedShoppingList,
|
||||
};
|
||||
},
|
||||
});
|
||||
state.shoppingListDialog = false;
|
||||
state.shoppingListIngredientDialog = false;
|
||||
dialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="css">
|
||||
|
|
|
@ -4,9 +4,9 @@
|
|||
v-model="dialog"
|
||||
width="800"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<BaseButton
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
@click="inputText = inputTextProp"
|
||||
>
|
||||
{{ $t("new-recipe.bulk-add") }}
|
||||
|
@ -89,88 +89,75 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
inputTextProp: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
emits: ["bulk-data"],
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
dialog: false,
|
||||
inputText: props.inputTextProp,
|
||||
});
|
||||
|
||||
function splitText() {
|
||||
return state.inputText.split("\n").filter(line => !(line === "\n" || !line));
|
||||
}
|
||||
|
||||
function removeFirstCharacter() {
|
||||
state.inputText = splitText()
|
||||
.map(line => line.substring(1))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const numberedLineRegex = /\d+[.):] /gm;
|
||||
|
||||
function splitByNumberedLine() {
|
||||
// Split inputText by numberedLineRegex
|
||||
const matches = state.inputText.match(numberedLineRegex);
|
||||
|
||||
matches?.forEach((match, idx) => {
|
||||
const replaceText = idx === 0 ? "" : "\n";
|
||||
state.inputText = state.inputText.replace(match, replaceText);
|
||||
});
|
||||
}
|
||||
|
||||
function trimAllLines() {
|
||||
const splitLines = splitText();
|
||||
|
||||
splitLines.forEach((element: string, index: number) => {
|
||||
splitLines[index] = element.trim();
|
||||
});
|
||||
|
||||
state.inputText = splitLines.join("\n");
|
||||
}
|
||||
|
||||
function save() {
|
||||
context.emit("bulk-data", splitText());
|
||||
state.dialog = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
{
|
||||
id: "trim-whitespace",
|
||||
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||
action: trimAllLines,
|
||||
},
|
||||
{
|
||||
id: "trim-prefix",
|
||||
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||
action: removeFirstCharacter,
|
||||
},
|
||||
{
|
||||
id: "split-by-numbered-line",
|
||||
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
||||
action: splitByNumberedLine,
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
utilities,
|
||||
splitText,
|
||||
trimAllLines,
|
||||
removeFirstCharacter,
|
||||
splitByNumberedLine,
|
||||
save,
|
||||
...toRefs(state),
|
||||
};
|
||||
},
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
inputTextProp?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
inputTextProp: "",
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"bulk-data": [data: string[]];
|
||||
}>();
|
||||
|
||||
const dialog = ref(false);
|
||||
const inputText = ref(props.inputTextProp);
|
||||
|
||||
function splitText() {
|
||||
return inputText.value.split("\n").filter(line => !(line === "\n" || !line));
|
||||
}
|
||||
|
||||
function removeFirstCharacter() {
|
||||
inputText.value = splitText()
|
||||
.map(line => line.substring(1))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const numberedLineRegex = /\d+[.):] /gm;
|
||||
|
||||
function splitByNumberedLine() {
|
||||
// Split inputText by numberedLineRegex
|
||||
const matches = inputText.value.match(numberedLineRegex);
|
||||
|
||||
matches?.forEach((match, idx) => {
|
||||
const replaceText = idx === 0 ? "" : "\n";
|
||||
inputText.value = inputText.value.replace(match, replaceText);
|
||||
});
|
||||
}
|
||||
|
||||
function trimAllLines() {
|
||||
const splitLines = splitText();
|
||||
|
||||
splitLines.forEach((element: string, index: number) => {
|
||||
splitLines[index] = element.trim();
|
||||
});
|
||||
|
||||
inputText.value = splitLines.join("\n");
|
||||
}
|
||||
|
||||
function save() {
|
||||
emit("bulk-data", splitText());
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const utilities = [
|
||||
{
|
||||
id: "trim-whitespace",
|
||||
description: i18n.t("new-recipe.trim-whitespace-description"),
|
||||
action: trimAllLines,
|
||||
},
|
||||
{
|
||||
id: "trim-prefix",
|
||||
description: i18n.t("new-recipe.trim-prefix-description"),
|
||||
action: removeFirstCharacter,
|
||||
},
|
||||
{
|
||||
id: "split-by-numbered-line",
|
||||
description: i18n.t("new-recipe.split-by-numbered-line-description"),
|
||||
action: splitByNumberedLine,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
|
|
@ -44,6 +44,7 @@
|
|||
<v-switch
|
||||
v-model="preferences.showDescription"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.description')"
|
||||
/>
|
||||
</v-row>
|
||||
|
@ -51,6 +52,7 @@
|
|||
<v-switch
|
||||
v-model="preferences.showNotes"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.notes')"
|
||||
/>
|
||||
</v-row>
|
||||
|
@ -63,6 +65,7 @@
|
|||
<v-switch
|
||||
v-model="preferences.showNutrition"
|
||||
hide-details
|
||||
color="primary"
|
||||
:label="$t('recipe.nutrition')"
|
||||
/>
|
||||
</v-row>
|
||||
|
@ -83,45 +86,19 @@
|
|||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
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 defineNuxtComponent({
|
||||
components: {
|
||||
RecipePrintView,
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
dialog,
|
||||
ImagePosition,
|
||||
preferences,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe?: NoUndefinedField<Recipe>;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), {
|
||||
recipe: undefined,
|
||||
});
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
const preferences = useUserPrintPreferences();
|
||||
</script>
|
||||
|
|
|
@ -52,10 +52,6 @@
|
|||
<div class="mr-auto">
|
||||
{{ $t("search.results") }}
|
||||
</div>
|
||||
<!-- <router-link
|
||||
:to="advancedSearchUrl"
|
||||
class="text-primary"
|
||||
> {{ $t("search.advanced-search") }} </router-link> -->
|
||||
</v-card-actions>
|
||||
|
||||
<RecipeCardMobile
|
||||
|
@ -76,7 +72,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
|
@ -85,114 +81,104 @@ import { useRecipeSearch } from "~/composables/recipes/use-recipe-search";
|
|||
import { usePublicExploreApi } from "~/composables/api/api-client";
|
||||
|
||||
const SELECTED_EVENT = "selected";
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeCardMobile,
|
||||
},
|
||||
|
||||
setup(_, context) {
|
||||
const $auth = useMealieAuth();
|
||||
const state = reactive({
|
||||
loading: false,
|
||||
selectedIndex: -1,
|
||||
});
|
||||
// Define emits
|
||||
const emit = defineEmits<{
|
||||
selected: [recipe: RecipeSummary];
|
||||
}>();
|
||||
|
||||
// ===========================================================================
|
||||
// Dialog State Management
|
||||
const dialog = ref(false);
|
||||
const $auth = useMealieAuth();
|
||||
const loading = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
|
||||
// Reset or Grab Recipes on Change
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
search.query.value = "";
|
||||
state.selectedIndex = -1;
|
||||
search.data.value = [];
|
||||
}
|
||||
});
|
||||
// ===========================================================================
|
||||
// Dialog State Management
|
||||
const dialog = ref(false);
|
||||
|
||||
// ===========================================================================
|
||||
// Event Handlers
|
||||
// Reset or Grab Recipes on Change
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
search.query.value = "";
|
||||
selectedIndex.value = -1;
|
||||
search.data.value = [];
|
||||
}
|
||||
});
|
||||
|
||||
function selectRecipe() {
|
||||
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||
if (recipeCards) {
|
||||
if (state.selectedIndex < 0) {
|
||||
state.selectedIndex = -1;
|
||||
document.getElementById("arrow-search")?.focus();
|
||||
return;
|
||||
}
|
||||
// ===========================================================================
|
||||
// Event Handlers
|
||||
|
||||
if (state.selectedIndex >= recipeCards.length) {
|
||||
state.selectedIndex = recipeCards.length - 1;
|
||||
}
|
||||
|
||||
(recipeCards[state.selectedIndex] as HTMLElement).focus();
|
||||
}
|
||||
function selectRecipe() {
|
||||
const recipeCards = document.getElementsByClassName("arrow-nav");
|
||||
if (recipeCards) {
|
||||
if (selectedIndex.value < 0) {
|
||||
selectedIndex.value = -1;
|
||||
document.getElementById("arrow-search")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
function onUpDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
console.log(document.activeElement);
|
||||
// (document.activeElement as HTMLElement).click();
|
||||
}
|
||||
else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
state.selectedIndex--;
|
||||
}
|
||||
else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
state.selectedIndex++;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
selectRecipe();
|
||||
if (selectedIndex.value >= recipeCards.length) {
|
||||
selectedIndex.value = recipeCards.length - 1;
|
||||
}
|
||||
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
}
|
||||
else {
|
||||
document.addEventListener("keyup", onUpDown);
|
||||
}
|
||||
});
|
||||
(recipeCards[selectedIndex.value] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
const route = useRoute();
|
||||
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`);
|
||||
watch(route, close);
|
||||
function onUpDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
console.log(document.activeElement);
|
||||
// (document.activeElement as HTMLElement).click();
|
||||
}
|
||||
else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
selectedIndex.value--;
|
||||
}
|
||||
else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
selectedIndex.value++;
|
||||
}
|
||||
else {
|
||||
return;
|
||||
}
|
||||
selectRecipe();
|
||||
}
|
||||
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
watch(dialog, (val) => {
|
||||
if (!val) {
|
||||
document.removeEventListener("keyup", onUpDown);
|
||||
}
|
||||
else {
|
||||
document.addEventListener("keyup", onUpDown);
|
||||
}
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// Basic Search
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
const search = useRecipeSearch(api);
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
watch(route, close);
|
||||
|
||||
// Select Handler
|
||||
function open() {
|
||||
dialog.value = true;
|
||||
}
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
function handleSelect(recipe: RecipeSummary) {
|
||||
close();
|
||||
context.emit(SELECTED_EVENT, recipe);
|
||||
}
|
||||
// ===========================================================================
|
||||
// Basic Search
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
|
||||
const search = useRecipeSearch(api);
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
advancedSearchUrl,
|
||||
dialog,
|
||||
open,
|
||||
close,
|
||||
handleSelect,
|
||||
search,
|
||||
};
|
||||
},
|
||||
// Select Handler
|
||||
function handleSelect(recipe: RecipeSummary) {
|
||||
close();
|
||||
emit(SELECTED_EVENT, recipe);
|
||||
}
|
||||
|
||||
// Expose functions to parent components
|
||||
defineExpose({
|
||||
open,
|
||||
close,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -14,14 +14,14 @@
|
|||
max-width="290px"
|
||||
min-width="auto"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
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="props"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
|
@ -92,150 +92,116 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useClipboard, useShare, whenever } from "@vueuse/core";
|
||||
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 defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
recipeId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
// V-Model Support
|
||||
const dialog = computed({
|
||||
get: () => {
|
||||
return props.modelValue;
|
||||
},
|
||||
set: (val) => {
|
||||
context.emit("update:modelValue", val);
|
||||
},
|
||||
});
|
||||
interface Props {
|
||||
recipeId: string;
|
||||
name: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const state = reactive({
|
||||
datePickerMenu: false,
|
||||
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
|
||||
tokens: [] as RecipeShareToken[],
|
||||
});
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const expirationDateString = computed(() => {
|
||||
return state.expirationDate.toISOString().substring(0, 10);
|
||||
});
|
||||
const datePickerMenu = ref(false);
|
||||
const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
|
||||
const tokens = ref<RecipeShareToken[]>([]);
|
||||
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
// Set expiration date to today + 30 Days
|
||||
const today = new Date();
|
||||
state.expirationDate = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
refreshTokens();
|
||||
},
|
||||
);
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Token Actions
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
async function createNewToken() {
|
||||
// Convert expiration date to timestamp
|
||||
const { data } = await userApi.recipes.share.createOne({
|
||||
recipeId: props.recipeId,
|
||||
expiresAt: state.expirationDate.toISOString(),
|
||||
});
|
||||
|
||||
if (data) {
|
||||
state.tokens.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteToken(id: string) {
|
||||
await userApi.recipes.share.deleteOne(id);
|
||||
state.tokens = state.tokens.filter(token => token.id !== id);
|
||||
}
|
||||
|
||||
async function refreshTokens() {
|
||||
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
||||
|
||||
if (data) {
|
||||
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
|
||||
state.tokens = data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const { share, isSupported: shareIsSupported } = useShare();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
function getRecipeText() {
|
||||
return i18n.t("recipe.share-recipe-message", [props.name]);
|
||||
}
|
||||
|
||||
function getTokenLink(token: string) {
|
||||
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
|
||||
}
|
||||
|
||||
async function copyTokenLink(token: string) {
|
||||
if (isSupported.value) {
|
||||
await copy(getTokenLink(token));
|
||||
if (copied.value) {
|
||||
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-copy-failure") as string);
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-not-supported") as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function shareRecipe(token: string) {
|
||||
if (shareIsSupported) {
|
||||
share({
|
||||
title: props.name,
|
||||
url: getTokenLink(token),
|
||||
text: getRecipeText() as string,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await copyTokenLink(token);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
expirationDateString,
|
||||
dialog,
|
||||
createNewToken,
|
||||
deleteToken,
|
||||
firstDayOfWeek,
|
||||
shareRecipe,
|
||||
copyTokenLink,
|
||||
};
|
||||
},
|
||||
const expirationDateString = computed(() => {
|
||||
return expirationDate.value.toISOString().substring(0, 10);
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => dialog.value,
|
||||
() => {
|
||||
// Set expiration date to today + 30 Days
|
||||
const today = new Date();
|
||||
expirationDate.value = new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
refreshTokens();
|
||||
},
|
||||
);
|
||||
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const { household } = useHouseholdSelf();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Token Actions
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
async function createNewToken() {
|
||||
// Convert expiration date to timestamp
|
||||
const { data } = await userApi.recipes.share.createOne({
|
||||
recipeId: props.recipeId,
|
||||
expiresAt: expirationDate.value.toISOString(),
|
||||
});
|
||||
|
||||
if (data) {
|
||||
tokens.value.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteToken(id: string) {
|
||||
await userApi.recipes.share.deleteOne(id);
|
||||
tokens.value = tokens.value.filter(token => token.id !== id);
|
||||
}
|
||||
|
||||
async function refreshTokens() {
|
||||
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
||||
|
||||
if (data) {
|
||||
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
|
||||
tokens.value = data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
const { share, isSupported: shareIsSupported } = useShare();
|
||||
const { copy, copied, isSupported } = useClipboard();
|
||||
|
||||
function getRecipeText() {
|
||||
return i18n.t("recipe.share-recipe-message", [props.name]);
|
||||
}
|
||||
|
||||
function getTokenLink(token: string) {
|
||||
return `${window.location.origin}/g/${groupSlug.value}/shared/r/${token}`;
|
||||
}
|
||||
|
||||
async function copyTokenLink(token: string) {
|
||||
if (isSupported.value) {
|
||||
await copy(getTokenLink(token));
|
||||
if (copied.value) {
|
||||
alert.success(i18n.t("recipe-share.recipe-link-copied-message") as string);
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-copy-failure") as string);
|
||||
}
|
||||
}
|
||||
else {
|
||||
alert.error(i18n.t("general.clipboard-not-supported") as string);
|
||||
}
|
||||
}
|
||||
|
||||
async function shareRecipe(token: string) {
|
||||
if (shareIsSupported) {
|
||||
share({
|
||||
title: props.name,
|
||||
url: getTokenLink(token),
|
||||
text: getRecipeText() as string,
|
||||
});
|
||||
}
|
||||
else {
|
||||
await copyTokenLink(token);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
v-if="isFavorite || showAlways"
|
||||
icon
|
||||
|
@ -13,7 +13,7 @@
|
|||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:fab="buttonStyle"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
v-bind="{ ...tooltipProps, ...$attrs }"
|
||||
@click.prevent="toggleFavorite"
|
||||
>
|
||||
<v-icon
|
||||
|
@ -28,47 +28,38 @@
|
|||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserSelfRatings } from "~/composables/use-users";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipeId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
showAlways: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
buttonStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const $auth = useMealieAuth();
|
||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
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($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
|
||||
return { isFavorite, toggleFavorite };
|
||||
},
|
||||
interface Props {
|
||||
recipeId?: string;
|
||||
showAlways?: boolean;
|
||||
buttonStyle?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipeId: "",
|
||||
showAlways: false,
|
||||
buttonStyle: false,
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const $auth = useMealieAuth();
|
||||
const { userRatings, refreshUserRatings } = useUserSelfRatings();
|
||||
|
||||
const isFavorite = computed(() => {
|
||||
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($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
else {
|
||||
await api.users.removeFavorite($auth.user.value?.id, props.recipeId);
|
||||
}
|
||||
await refreshUserRatings();
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -7,11 +7,11 @@
|
|||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
color="accent"
|
||||
dark
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
>
|
||||
<v-icon start>
|
||||
{{ $globals.icons.fileImage }}
|
||||
|
@ -61,52 +61,42 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
|
||||
const REFRESH_EVENT = "refresh";
|
||||
const UPLOAD_EVENT = "upload";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const state = reactive({
|
||||
url: "",
|
||||
loading: false,
|
||||
menu: false,
|
||||
});
|
||||
const props = defineProps<{ slug: string }>();
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
context.emit(UPLOAD_EVENT, fileObject);
|
||||
state.menu = false;
|
||||
}
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
upload: [fileObject: File];
|
||||
}>();
|
||||
|
||||
const api = useUserApi();
|
||||
async function getImageFromURL() {
|
||||
state.loading = true;
|
||||
if (await api.recipes.updateImagebyURL(props.slug, state.url)) {
|
||||
context.emit(REFRESH_EVENT);
|
||||
}
|
||||
state.loading = false;
|
||||
state.menu = false;
|
||||
}
|
||||
const url = ref("");
|
||||
const loading = ref(false);
|
||||
const menu = ref(false);
|
||||
|
||||
const i18n = useI18n();
|
||||
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")];
|
||||
function uploadImage(fileObject: File) {
|
||||
emit(UPLOAD_EVENT, fileObject);
|
||||
menu.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
uploadImage,
|
||||
getImageFromURL,
|
||||
messages,
|
||||
};
|
||||
},
|
||||
});
|
||||
const api = useUserApi();
|
||||
async function getImageFromURL() {
|
||||
loading.value = true;
|
||||
if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
|
||||
emit(REFRESH_EVENT);
|
||||
}
|
||||
loading.value = false;
|
||||
menu.value = false;
|
||||
}
|
||||
|
||||
const i18n = useI18n();
|
||||
const messages = computed(() =>
|
||||
props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")],
|
||||
);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
|
|
@ -3,21 +3,13 @@
|
|||
<div v-html="safeMarkup" />
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
markup: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||
return {
|
||||
safeMarkup,
|
||||
};
|
||||
},
|
||||
});
|
||||
interface Props {
|
||||
markup: string;
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
|
||||
</script>
|
||||
|
|
|
@ -28,34 +28,22 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import type { RecipeIngredient } from "~/lib/api/types/household";
|
||||
import { useParsedIngredientText } from "~/composables/recipes";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
ingredient: {
|
||||
type: Object as () => RecipeIngredient,
|
||||
required: true,
|
||||
},
|
||||
disableAmount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
|
||||
});
|
||||
interface Props {
|
||||
ingredient: RecipeIngredient;
|
||||
disableAmount?: boolean;
|
||||
scale?: number;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disableAmount: false,
|
||||
scale: 1,
|
||||
});
|
||||
|
||||
return {
|
||||
parsedIng,
|
||||
};
|
||||
},
|
||||
const parsedIng = computed(() => {
|
||||
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -53,71 +53,53 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
|
||||
import { parseIngredientText } from "~/composables/recipes";
|
||||
import type { RecipeIngredient } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeIngredientListItem },
|
||||
props: {
|
||||
value: {
|
||||
type: Array as () => RecipeIngredient[],
|
||||
default: () => [],
|
||||
},
|
||||
disableAmount: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
function validateTitle(title?: string) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
checked: props.value.map(() => false),
|
||||
showTitleEditor: computed(() => props.value.map(x => validateTitle(x.title))),
|
||||
});
|
||||
|
||||
const ingredientCopyText = computed(() => {
|
||||
const components: string[] = [];
|
||||
props.value.forEach((ingredient) => {
|
||||
if (ingredient.title) {
|
||||
if (components.length) {
|
||||
components.push("");
|
||||
}
|
||||
|
||||
components.push(`[${ingredient.title}]`);
|
||||
}
|
||||
|
||||
components.push(parseIngredientText(ingredient, props.disableAmount, props.scale, false));
|
||||
});
|
||||
|
||||
return components.join("\n");
|
||||
});
|
||||
|
||||
function toggleChecked(index: number) {
|
||||
// TODO Find a better way to do this - $set is not available, and
|
||||
// direct array modifications are not propagated for some reason
|
||||
state.checked.splice(index, 1, !state.checked[index]);
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
ingredientCopyText,
|
||||
toggleChecked,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
value?: RecipeIngredient[];
|
||||
disableAmount?: boolean;
|
||||
scale?: number;
|
||||
isCookMode?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
value: () => [],
|
||||
disableAmount: false,
|
||||
scale: 1,
|
||||
isCookMode: false,
|
||||
});
|
||||
|
||||
function validateTitle(title?: string | null) {
|
||||
return !(title === undefined || title === "" || title === null);
|
||||
}
|
||||
|
||||
const checked = ref(props.value.map(() => false));
|
||||
const showTitleEditor = computed(() => props.value.map(x => validateTitle(x.title)));
|
||||
|
||||
const ingredientCopyText = computed(() => {
|
||||
const components: string[] = [];
|
||||
props.value.forEach((ingredient) => {
|
||||
if (ingredient.title) {
|
||||
if (components.length) {
|
||||
components.push("");
|
||||
}
|
||||
|
||||
components.push(`[${ingredient.title}]`);
|
||||
}
|
||||
|
||||
components.push(parseIngredientText(ingredient, props.disableAmount, props.scale, false));
|
||||
});
|
||||
|
||||
return components.join("\n");
|
||||
});
|
||||
|
||||
function toggleChecked(index: number) {
|
||||
// TODO Find a better way to do this - $set is not available, and
|
||||
// direct array modifications are not propagated for some reason
|
||||
checked.value.splice(index, 1, !checked.value[index]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
|
|
@ -30,11 +30,11 @@
|
|||
offset-y
|
||||
max-width="290px"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-text-field
|
||||
v-model="newTimelineEventTimestampString"
|
||||
:prepend-icon="$globals.icons.calendar"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
readonly
|
||||
/>
|
||||
</template>
|
||||
|
@ -87,12 +87,12 @@
|
|||
<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-tooltip location="bottom">
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: tooltipProps }">
|
||||
<v-btn
|
||||
rounded
|
||||
variant="outlined"
|
||||
size="x-large"
|
||||
v-bind="props"
|
||||
v-bind="tooltipProps"
|
||||
style="border-color: rgb(var(--v-theme-primary));"
|
||||
@click="madeThisDialog = true"
|
||||
>
|
||||
|
@ -117,7 +117,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { whenever } from "@vueuse/core";
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { alert } from "~/composables/use-toast";
|
||||
|
@ -125,180 +125,157 @@ import { useHouseholdSelf } from "~/composables/use-households";
|
|||
import type { Recipe, RecipeTimelineEventIn, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
|
||||
import type { VForm } from "~/types/auto-forms";
|
||||
|
||||
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 i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
eventType: "comment",
|
||||
eventMessage: "",
|
||||
timestamp: undefined,
|
||||
recipeId: props.recipe?.id || "",
|
||||
});
|
||||
const newTimelineEventImage = ref<Blob | File>();
|
||||
const newTimelineEventImageName = ref<string>("");
|
||||
const newTimelineEventImagePreviewUrl = ref<string>();
|
||||
const newTimelineEventTimestamp = ref<Date>(new Date());
|
||||
const newTimelineEventTimestampString = computed(() => {
|
||||
return newTimelineEventTimestamp.value.toISOString().substring(0, 10);
|
||||
});
|
||||
const props = defineProps<{ recipe: Recipe }>();
|
||||
const emit = defineEmits<{
|
||||
eventCreated: [event: RecipeTimelineEventOut];
|
||||
}>();
|
||||
|
||||
const lastMade = ref(props.recipe.lastMade);
|
||||
const lastMadeReady = ref(false);
|
||||
onMounted(async () => {
|
||||
if (!$auth.user?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||
lastMade.value = data?.lastMade;
|
||||
}
|
||||
|
||||
lastMadeReady.value = true;
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => madeThisDialog.value,
|
||||
() => {
|
||||
// Set timestamp to now
|
||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||
},
|
||||
);
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
function clearImage() {
|
||||
newTimelineEventImage.value = undefined;
|
||||
newTimelineEventImageName.value = "";
|
||||
newTimelineEventImagePreviewUrl.value = undefined;
|
||||
}
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImageName.value = fileObject.name;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
function updateUploadedImage(fileObject: Blob) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
const state = reactive({ datePickerMenu: false, madeThisFormLoading: false });
|
||||
|
||||
function resetMadeThisForm() {
|
||||
state.madeThisFormLoading = false;
|
||||
|
||||
newTimelineEvent.value.eventMessage = "";
|
||||
newTimelineEvent.value.timestamp = undefined;
|
||||
clearImage();
|
||||
madeThisDialog.value = false;
|
||||
domMadeThisForm.value?.reset();
|
||||
}
|
||||
|
||||
async function createTimelineEvent() {
|
||||
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
state.madeThisFormLoading = true;
|
||||
|
||||
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(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
||||
|
||||
let newEvent: RecipeTimelineEventOut | null = null;
|
||||
try {
|
||||
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
||||
newEvent = eventResponse.data;
|
||||
if (!newEvent) {
|
||||
throw new Error("No event created");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to create timeline event:", error);
|
||||
alert.error(i18n.t("recipe.failed-to-add-to-timeline"));
|
||||
resetMadeThisForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// we also update the recipe's last made value
|
||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||
try {
|
||||
lastMade.value = newTimelineEvent.value.timestamp;
|
||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to update last made date:", error);
|
||||
alert.error(i18n.t("recipe.failed-to-update-recipe"));
|
||||
}
|
||||
}
|
||||
|
||||
// update the image, if provided
|
||||
let imageError = false;
|
||||
if (newTimelineEventImage.value) {
|
||||
try {
|
||||
const imageResponse = await userApi.recipes.updateTimelineEventImage(
|
||||
newEvent.id,
|
||||
newTimelineEventImage.value,
|
||||
newTimelineEventImageName.value,
|
||||
);
|
||||
if (imageResponse.data) {
|
||||
newEvent.image = imageResponse.data.image;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
imageError = true;
|
||||
console.error("Failed to upload image for timeline event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageError) {
|
||||
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||
}
|
||||
else {
|
||||
alert.success(i18n.t("recipe.added-to-timeline"));
|
||||
}
|
||||
|
||||
resetMadeThisForm();
|
||||
context.emit("eventCreated", newEvent);
|
||||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
domMadeThisForm,
|
||||
madeThisDialog,
|
||||
firstDayOfWeek,
|
||||
newTimelineEvent,
|
||||
newTimelineEventImage,
|
||||
newTimelineEventImagePreviewUrl,
|
||||
newTimelineEventTimestamp,
|
||||
newTimelineEventTimestampString,
|
||||
lastMade,
|
||||
lastMadeReady,
|
||||
createTimelineEvent,
|
||||
clearImage,
|
||||
uploadImage,
|
||||
updateUploadedImage,
|
||||
};
|
||||
},
|
||||
const madeThisDialog = ref(false);
|
||||
const userApi = useUserApi();
|
||||
const { household } = useHouseholdSelf();
|
||||
const i18n = useI18n();
|
||||
const $auth = useMealieAuth();
|
||||
const domMadeThisForm = ref<VForm>();
|
||||
const newTimelineEvent = ref<RecipeTimelineEventIn>({
|
||||
subject: "",
|
||||
eventType: "comment",
|
||||
eventMessage: "",
|
||||
timestamp: undefined,
|
||||
recipeId: props.recipe?.id || "",
|
||||
});
|
||||
const newTimelineEventImage = ref<Blob | File>();
|
||||
const newTimelineEventImageName = ref<string>("");
|
||||
const newTimelineEventImagePreviewUrl = 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?.value?.householdSlug) {
|
||||
lastMade.value = props.recipe.lastMade;
|
||||
}
|
||||
else {
|
||||
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || "");
|
||||
lastMade.value = data?.lastMade;
|
||||
}
|
||||
|
||||
lastMadeReady.value = true;
|
||||
});
|
||||
|
||||
whenever(
|
||||
() => madeThisDialog.value,
|
||||
() => {
|
||||
// Set timestamp to now
|
||||
newTimelineEventTimestamp.value = new Date(Date.now() - new Date().getTimezoneOffset() * 60000);
|
||||
},
|
||||
);
|
||||
|
||||
const firstDayOfWeek = computed(() => {
|
||||
return household.value?.preferences?.firstDayOfWeek || 0;
|
||||
});
|
||||
|
||||
function clearImage() {
|
||||
newTimelineEventImage.value = undefined;
|
||||
newTimelineEventImageName.value = "";
|
||||
newTimelineEventImagePreviewUrl.value = undefined;
|
||||
}
|
||||
|
||||
function uploadImage(fileObject: File) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImageName.value = fileObject.name;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
function updateUploadedImage(fileObject: Blob) {
|
||||
newTimelineEventImage.value = fileObject;
|
||||
newTimelineEventImagePreviewUrl.value = URL.createObjectURL(fileObject);
|
||||
}
|
||||
|
||||
const datePickerMenu = ref(false);
|
||||
const madeThisFormLoading = ref(false);
|
||||
|
||||
function resetMadeThisForm() {
|
||||
madeThisFormLoading.value = false;
|
||||
|
||||
newTimelineEvent.value.eventMessage = "";
|
||||
newTimelineEvent.value.timestamp = undefined;
|
||||
clearImage();
|
||||
madeThisDialog.value = false;
|
||||
domMadeThisForm.value?.reset();
|
||||
}
|
||||
|
||||
async function createTimelineEvent() {
|
||||
if (!(newTimelineEventTimestampString.value && props.recipe?.id && props.recipe?.slug)) {
|
||||
return;
|
||||
}
|
||||
|
||||
madeThisFormLoading.value = true;
|
||||
|
||||
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(newTimelineEventTimestampString.value + "T23:59:59").toISOString();
|
||||
|
||||
let newEvent: RecipeTimelineEventOut | null = null;
|
||||
try {
|
||||
const eventResponse = await userApi.recipes.createTimelineEvent(newTimelineEvent.value);
|
||||
newEvent = eventResponse.data;
|
||||
if (!newEvent) {
|
||||
throw new Error("No event created");
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to create timeline event:", error);
|
||||
alert.error(i18n.t("recipe.failed-to-add-to-timeline"));
|
||||
resetMadeThisForm();
|
||||
return;
|
||||
}
|
||||
|
||||
// we also update the recipe's last made value
|
||||
if (!lastMade.value || newTimelineEvent.value.timestamp > lastMade.value) {
|
||||
try {
|
||||
lastMade.value = newTimelineEvent.value.timestamp;
|
||||
await userApi.recipes.updateLastMade(props.recipe.slug, newTimelineEvent.value.timestamp);
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Failed to update last made date:", error);
|
||||
alert.error(i18n.t("recipe.failed-to-update-recipe"));
|
||||
}
|
||||
}
|
||||
|
||||
// update the image, if provided
|
||||
let imageError = false;
|
||||
if (newTimelineEventImage.value) {
|
||||
try {
|
||||
const imageResponse = await userApi.recipes.updateTimelineEventImage(
|
||||
newEvent.id,
|
||||
newTimelineEventImage.value,
|
||||
newTimelineEventImageName.value,
|
||||
);
|
||||
if (imageResponse.data) {
|
||||
newEvent.image = imageResponse.data.image;
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
imageError = true;
|
||||
console.error("Failed to upload image for timeline event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageError) {
|
||||
alert.error(i18n.t("recipe.added-to-timeline-but-failed-to-add-image"));
|
||||
}
|
||||
else {
|
||||
alert.success(i18n.t("recipe.added-to-timeline"));
|
||||
}
|
||||
|
||||
resetMadeThisForm();
|
||||
emit("eventCreated", newEvent);
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -51,141 +51,121 @@
|
|||
</v-list>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "dompurify";
|
||||
import { useFraction } from "~/composables/recipes/use-fraction";
|
||||
import type { ShoppingListItemOut } from "~/lib/api/types/household";
|
||||
import type { RecipeSummary } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
recipes: {
|
||||
type: Array as () => RecipeSummary[],
|
||||
required: true,
|
||||
},
|
||||
listItem: {
|
||||
type: Object as () => ShoppingListItemOut | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
tile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
showDescription: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const $auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
interface Props {
|
||||
recipes: RecipeSummary[];
|
||||
listItem?: ShoppingListItemOut;
|
||||
small?: boolean;
|
||||
tile?: boolean;
|
||||
showDescription?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
listItem: undefined,
|
||||
small: false,
|
||||
tile: false,
|
||||
showDescription: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
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: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
const $auth = useMealieAuth();
|
||||
const { frac } = useFraction();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
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: "",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
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(_ => "");
|
||||
}
|
||||
|
||||
const listItemDescriptions: string[] = [];
|
||||
for (let i = 0; i < props.recipes.length; i++) {
|
||||
const itemRef = props.listItem?.recipeReferences[i];
|
||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||
|
||||
let listItemDescription = "";
|
||||
if (props.listItem.unit?.fraction) {
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||
}
|
||||
|
||||
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(_ => "");
|
||||
}
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation
|
||||
: props.listItem.unit.name;
|
||||
|
||||
const listItemDescriptions: string[] = [];
|
||||
for (let i = 0; i < props.recipes.length; i++) {
|
||||
const itemRef = props.listItem?.recipeReferences[i];
|
||||
const quantity = (itemRef.recipeQuantity || 1) * (itemRef.recipeScale || 1);
|
||||
listItemDescription += ` ${unitDisplay}`;
|
||||
}
|
||||
|
||||
let listItemDescription = "";
|
||||
if (props.listItem.unit?.fraction) {
|
||||
const fraction = frac(quantity, 10, true);
|
||||
if (fraction[0] !== undefined && fraction[0] > 0) {
|
||||
listItemDescription += fraction[0];
|
||||
}
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||
}
|
||||
|
||||
if (fraction[1] > 0) {
|
||||
listItemDescription += ` <sup>${fraction[1]}</sup>⁄<sub>${fraction[2]}</sub>`;
|
||||
}
|
||||
else {
|
||||
listItemDescription = (quantity).toString();
|
||||
}
|
||||
}
|
||||
else {
|
||||
listItemDescription = (Math.round(quantity * 100) / 100).toString();
|
||||
}
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
}
|
||||
|
||||
if (props.listItem.unit) {
|
||||
const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
|
||||
? props.listItem.unit.abbreviation
|
||||
: props.listItem.unit.name;
|
||||
|
||||
listItemDescription += ` ${unitDisplay}`;
|
||||
}
|
||||
|
||||
if (itemRef.recipeNote) {
|
||||
listItemDescription += `, ${itemRef.recipeNote}`;
|
||||
}
|
||||
|
||||
listItemDescriptions.push(sanitizeHTML(listItemDescription));
|
||||
}
|
||||
|
||||
return listItemDescriptions;
|
||||
});
|
||||
|
||||
return {
|
||||
attrs,
|
||||
groupSlug,
|
||||
listItemDescriptions,
|
||||
};
|
||||
},
|
||||
return listItemDescriptions;
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -45,62 +45,48 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useNutritionLabels } from "~/composables/recipes";
|
||||
import type { Nutrition } from "~/lib/api/types/recipe";
|
||||
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object as () => Nutrition,
|
||||
required: true,
|
||||
},
|
||||
edit: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const { labels } = useNutritionLabels();
|
||||
const valueNotNull = computed(() => {
|
||||
let key: keyof Nutrition;
|
||||
for (key in props.modelValue) {
|
||||
if (props.modelValue[key] !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
interface Props {
|
||||
edit?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
edit: true,
|
||||
});
|
||||
|
||||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||
const modelValue = defineModel<Nutrition>({ required: true });
|
||||
|
||||
function updateValue(key: number | string, event: Event) {
|
||||
context.emit("update:modelValue", { ...props.modelValue, [key]: event });
|
||||
const { labels } = useNutritionLabels();
|
||||
const valueNotNull = computed(() => {
|
||||
let key: keyof Nutrition;
|
||||
for (key in modelValue.value) {
|
||||
if (modelValue.value[key] !== null) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// 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.modelValue[key]?.trim()) {
|
||||
item[key] = {
|
||||
...label,
|
||||
value: props.modelValue[key],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}, {});
|
||||
});
|
||||
const showViewer = computed(() => !props.edit && valueNotNull.value);
|
||||
|
||||
return {
|
||||
labels,
|
||||
valueNotNull,
|
||||
showViewer,
|
||||
updateValue,
|
||||
renderedList,
|
||||
};
|
||||
},
|
||||
function updateValue(key: number | string, event: Event) {
|
||||
modelValue.value = { ...modelValue.value, [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 (modelValue.value[key]?.trim()) {
|
||||
item[key] = {
|
||||
...label,
|
||||
value: modelValue.value[key],
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}, {});
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -60,119 +60,93 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useUserApi } from "~/composables/api";
|
||||
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
|
||||
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
|
||||
|
||||
const CREATED_ITEM_EVENT = "created-item";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
tagDialog: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
itemType: {
|
||||
type: String as () => RecipeOrganizer,
|
||||
default: "category",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, context) {
|
||||
const i18n = useI18n();
|
||||
|
||||
const state = reactive({
|
||||
name: "",
|
||||
onHand: false,
|
||||
});
|
||||
|
||||
const dialog = computed({
|
||||
get() {
|
||||
return props.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
context.emit("update:modelValue", value);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val: boolean) => {
|
||||
if (!val) state.name = "";
|
||||
},
|
||||
);
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
const store = (() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
|
||||
const properties = computed(() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return {
|
||||
title: i18n.t("tag.create-a-tag"),
|
||||
label: i18n.t("tag.tag-name"),
|
||||
api: userApi.tags,
|
||||
};
|
||||
case Organizer.Tool:
|
||||
return {
|
||||
title: i18n.t("tool.create-a-tool"),
|
||||
label: i18n.t("tool.tool-name"),
|
||||
api: userApi.tools,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: i18n.t("category.create-a-category"),
|
||||
label: i18n.t("category.category-name"),
|
||||
api: userApi.categories,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
|
||||
};
|
||||
|
||||
async function select() {
|
||||
if (store) {
|
||||
// @ts-expect-error the same state is used for different organizer types, which have different requirements
|
||||
await store.actions.createOne({ ...state });
|
||||
}
|
||||
|
||||
const newItem = store.store.value.find(item => item.name === state.name);
|
||||
|
||||
context.emit(CREATED_ITEM_EVENT, newItem);
|
||||
dialog.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
Organizer,
|
||||
...toRefs(state),
|
||||
dialog,
|
||||
properties,
|
||||
rules,
|
||||
select,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
color?: string | null;
|
||||
tagDialog?: boolean;
|
||||
itemType?: RecipeOrganizer;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: null,
|
||||
tagDialog: true,
|
||||
itemType: "category" as RecipeOrganizer,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"created-item": [item: any];
|
||||
}>();
|
||||
|
||||
const dialog = defineModel<boolean>({ default: false });
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const name = ref("");
|
||||
const onHand = ref(false);
|
||||
|
||||
watch(
|
||||
dialog,
|
||||
(val: boolean) => {
|
||||
if (!val) name.value = "";
|
||||
},
|
||||
);
|
||||
|
||||
const userApi = useUserApi();
|
||||
|
||||
const store = (() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return useTagStore();
|
||||
case Organizer.Tool:
|
||||
return useToolStore();
|
||||
default:
|
||||
return useCategoryStore();
|
||||
}
|
||||
})();
|
||||
|
||||
const properties = computed(() => {
|
||||
switch (props.itemType) {
|
||||
case Organizer.Tag:
|
||||
return {
|
||||
title: i18n.t("tag.create-a-tag"),
|
||||
label: i18n.t("tag.tag-name"),
|
||||
api: userApi.tags,
|
||||
};
|
||||
case Organizer.Tool:
|
||||
return {
|
||||
title: i18n.t("tool.create-a-tool"),
|
||||
label: i18n.t("tool.tool-name"),
|
||||
api: userApi.tools,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: i18n.t("category.create-a-category"),
|
||||
label: i18n.t("category.category-name"),
|
||||
api: userApi.categories,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const rules = {
|
||||
required: (val: string) => !!val || (i18n.t("general.a-name-is-required") as string),
|
||||
};
|
||||
|
||||
async function select() {
|
||||
if (store) {
|
||||
// @ts-expect-error the same state is used for different organizer types, which have different requirements
|
||||
await store.actions.createOne({ name: name.value, onHand: onHand.value });
|
||||
}
|
||||
|
||||
const newItem = store.store.value.find(item => item.name === name.value);
|
||||
|
||||
emit(CREATED_ITEM_EVENT, newItem);
|
||||
dialog.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
|
|
|
@ -122,9 +122,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import { useContextPresets } from "~/composables/use-context-presents";
|
||||
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
|
||||
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
|
||||
|
@ -138,156 +137,128 @@ interface GenericItem {
|
|||
onHand: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeOrganizerDialog,
|
||||
},
|
||||
props: {
|
||||
items: {
|
||||
type: Array as () => GenericItem[],
|
||||
required: true,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
itemType: {
|
||||
type: String as () => RecipeOrganizer,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update", "delete"],
|
||||
setup(props, { emit }) {
|
||||
const state = reactive({
|
||||
// Search Options
|
||||
options: {
|
||||
ignoreLocation: true,
|
||||
shouldSort: true,
|
||||
threshold: 0.2,
|
||||
location: 0,
|
||||
distance: 20,
|
||||
findAllMatches: true,
|
||||
maxPatternLength: 32,
|
||||
minMatchCharLength: 1,
|
||||
keys: ["name"],
|
||||
},
|
||||
});
|
||||
const props = defineProps<{
|
||||
items: GenericItem[];
|
||||
icon: string;
|
||||
itemType: RecipeOrganizer;
|
||||
}>();
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
const emit = defineEmits<{
|
||||
update: [item: GenericItem];
|
||||
delete: [id: string];
|
||||
}>();
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
||||
const dialogs = ref({
|
||||
organizer: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
});
|
||||
|
||||
const presets = useContextPresets();
|
||||
|
||||
const translationKey = computed<string>(() => {
|
||||
const typeMap = {
|
||||
categories: "category.category",
|
||||
tags: "tag.tag",
|
||||
tools: "tool.tool",
|
||||
foods: "shopping-list.food",
|
||||
households: "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
|
||||
const deleteTarget = ref<GenericItem | null>(null);
|
||||
const updateTarget = ref<GenericItem | null>(null);
|
||||
|
||||
function confirmDelete(item: GenericItem) {
|
||||
deleteTarget.value = item;
|
||||
dialogs.value.delete = true;
|
||||
}
|
||||
|
||||
function deleteOne() {
|
||||
if (!deleteTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("delete", deleteTarget.value.id);
|
||||
}
|
||||
|
||||
function openUpdateDialog(item: GenericItem) {
|
||||
updateTarget.value = deepCopy(item);
|
||||
dialogs.value.update = true;
|
||||
}
|
||||
|
||||
function updateOne() {
|
||||
if (!updateTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update", updateTarget.value);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Search Functions
|
||||
|
||||
const searchString = useRouteQuery("q", "");
|
||||
|
||||
const fuse = computed(() => {
|
||||
return new Fuse(props.items, state.options);
|
||||
});
|
||||
|
||||
const fuzzyItems = computed<GenericItem[]>(() => {
|
||||
if (searchString.value.trim() === "") {
|
||||
return props.items;
|
||||
}
|
||||
const result = fuse.value.search(searchString.value.trim() as string);
|
||||
return result.map(x => x.item);
|
||||
});
|
||||
|
||||
// =================================================================
|
||||
// Sorted Items
|
||||
|
||||
const itemsSorted = computed(() => {
|
||||
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
||||
|
||||
if (!fuzzyItems.value) {
|
||||
return byLetter;
|
||||
}
|
||||
|
||||
[...fuzzyItems.value]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
if (!byLetter[letter]) {
|
||||
byLetter[letter] = [];
|
||||
}
|
||||
byLetter[letter].push(item);
|
||||
});
|
||||
|
||||
return byLetter;
|
||||
});
|
||||
|
||||
function isTitle(str: number | string) {
|
||||
return typeof str === "string" && str.length === 1;
|
||||
}
|
||||
|
||||
return {
|
||||
groupSlug,
|
||||
isTitle,
|
||||
dialogs,
|
||||
confirmDelete,
|
||||
openUpdateDialog,
|
||||
updateOne,
|
||||
updateTarget,
|
||||
deleteOne,
|
||||
deleteTarget,
|
||||
Organizer,
|
||||
presets,
|
||||
itemsSorted,
|
||||
searchString,
|
||||
translationKey,
|
||||
};
|
||||
const state = reactive({
|
||||
// Search Options
|
||||
options: {
|
||||
ignoreLocation: true,
|
||||
shouldSort: true,
|
||||
threshold: 0.2,
|
||||
location: 0,
|
||||
distance: 20,
|
||||
findAllMatches: true,
|
||||
maxPatternLength: 32,
|
||||
minMatchCharLength: 1,
|
||||
keys: ["name"],
|
||||
},
|
||||
});
|
||||
|
||||
const $auth = useMealieAuth();
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || "");
|
||||
|
||||
// =================================================================
|
||||
// Context Menu
|
||||
|
||||
const dialogs = ref({
|
||||
organizer: false,
|
||||
update: false,
|
||||
delete: false,
|
||||
});
|
||||
|
||||
const presets = useContextPresets();
|
||||
|
||||
const translationKey = computed<string>(() => {
|
||||
const typeMap = {
|
||||
categories: "category.category",
|
||||
tags: "tag.tag",
|
||||
tools: "tool.tool",
|
||||
foods: "shopping-list.food",
|
||||
households: "household.household",
|
||||
};
|
||||
return typeMap[props.itemType] || "";
|
||||
});
|
||||
|
||||
const deleteTarget = ref<GenericItem | null>(null);
|
||||
const updateTarget = ref<GenericItem | null>(null);
|
||||
|
||||
function confirmDelete(item: GenericItem) {
|
||||
deleteTarget.value = item;
|
||||
dialogs.value.delete = true;
|
||||
}
|
||||
|
||||
function deleteOne() {
|
||||
if (!deleteTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("delete", deleteTarget.value.id);
|
||||
}
|
||||
|
||||
function openUpdateDialog(item: GenericItem) {
|
||||
updateTarget.value = deepCopy(item);
|
||||
dialogs.value.update = true;
|
||||
}
|
||||
|
||||
function updateOne() {
|
||||
if (!updateTarget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit("update", updateTarget.value);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Search Functions
|
||||
|
||||
const searchString = useRouteQuery("q", "");
|
||||
|
||||
const fuse = computed(() => {
|
||||
return new Fuse(props.items, state.options);
|
||||
});
|
||||
|
||||
const fuzzyItems = computed<GenericItem[]>(() => {
|
||||
if (searchString.value.trim() === "") {
|
||||
return props.items;
|
||||
}
|
||||
const result = fuse.value.search(searchString.value.trim() as string);
|
||||
return result.map(x => x.item);
|
||||
});
|
||||
|
||||
// =================================================================
|
||||
// Sorted Items
|
||||
|
||||
const itemsSorted = computed(() => {
|
||||
const byLetter: { [key: string]: Array<GenericItem> } = {};
|
||||
|
||||
if (!fuzzyItems.value) {
|
||||
return byLetter;
|
||||
}
|
||||
|
||||
[...fuzzyItems.value]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.forEach((item) => {
|
||||
const letter = item.name[0].toUpperCase();
|
||||
if (!byLetter[letter]) {
|
||||
byLetter[letter] = [];
|
||||
}
|
||||
byLetter[letter].push(item);
|
||||
});
|
||||
|
||||
return byLetter;
|
||||
});
|
||||
|
||||
function isTitle(str: number | string) {
|
||||
return typeof str === "string" && str.length === 1;
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
|
||||
</div>
|
||||
<div>
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
<RecipePageScale v-model="scale" :recipe="recipe" />
|
||||
</div>
|
||||
|
||||
<!--
|
||||
|
@ -96,7 +96,7 @@
|
|||
<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 v-model:scale="scale" :recipe="recipe" />
|
||||
<RecipePageScale v-model="scale" :recipe="recipe" />
|
||||
</div>
|
||||
<RecipePageIngredientToolsView
|
||||
v-if="!isEditForm"
|
||||
|
@ -124,7 +124,7 @@
|
|||
</v-sheet>
|
||||
<v-sheet v-show="isCookMode && hasLinkedIngredients">
|
||||
<div class="mt-2 px-2 px-md-4">
|
||||
<RecipePageScale v-model:scale="scale" :recipe="recipe" />
|
||||
<RecipePageScale v-model="scale" :recipe="recipe" />
|
||||
</div>
|
||||
<RecipePageInstructions
|
||||
v-model="recipe.recipeInstructions"
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { useRecipePermissions } from "~/composables/recipes";
|
||||
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue";
|
||||
|
@ -35,82 +35,48 @@ 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";
|
||||
import { usePageState, usePageUser, PageMode } from "~/composables/recipe-page/shared-state";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePageInfoCard,
|
||||
RecipeActionMenu,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
recipeScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["save", "delete"],
|
||||
setup(props) {
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, pageMode, editMode, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const recipeHousehold = ref<HouseholdSummary>();
|
||||
if (user) {
|
||||
const userApi = useUserApi();
|
||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||
recipeHousehold.value = data || undefined;
|
||||
});
|
||||
}
|
||||
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
|
||||
|
||||
function printRecipe() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
setMode,
|
||||
toggleEditMode,
|
||||
recipeImage,
|
||||
canEditRecipe,
|
||||
imageKey,
|
||||
user,
|
||||
PageMode,
|
||||
pageMode,
|
||||
EditorMode,
|
||||
editMode,
|
||||
printRecipe,
|
||||
imageHeight,
|
||||
hideImage,
|
||||
isEditMode,
|
||||
recipeImageUrl,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
recipeScale?: number;
|
||||
landscape?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipeScale: 1,
|
||||
landscape: false,
|
||||
});
|
||||
|
||||
defineEmits(["save", "delete"]);
|
||||
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey, setMode, toggleEditMode, isEditMode } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
const recipeHousehold = ref<HouseholdSummary>();
|
||||
if (user) {
|
||||
const userApi = useUserApi();
|
||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||
recipeHousehold.value = data || undefined;
|
||||
});
|
||||
}
|
||||
const { canEditRecipe } = useRecipePermissions(props.recipe, recipeHousehold, user);
|
||||
|
||||
function printRecipe() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -76,7 +76,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.vue";
|
||||
|
@ -86,34 +86,15 @@ import RecipePageInfoCardImage from "~/components/Domain/Recipe/RecipePage/Recip
|
|||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
import type { NoUndefinedField } from "~/lib/api/types/non-generated";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeRating,
|
||||
RecipeLastMade,
|
||||
RecipeTimeCard,
|
||||
RecipeYield,
|
||||
RecipePageInfoCardImage,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
recipeScale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
landscape: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
recipeScale?: number;
|
||||
landscape: boolean;
|
||||
}
|
||||
|
||||
return {
|
||||
isOwnGroup,
|
||||
};
|
||||
},
|
||||
withDefaults(defineProps<Props>(), {
|
||||
recipeScale: 1,
|
||||
});
|
||||
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
</script>
|
||||
|
|
|
@ -12,60 +12,47 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
||||
import type { HouseholdSummary } from "~/lib/api/types/household";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
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>,
|
||||
required: true,
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
|
||||
const recipeHousehold = ref<HouseholdSummary>();
|
||||
if (user) {
|
||||
const userApi = useUserApi();
|
||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||
recipeHousehold.value = data || undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
recipeImageUrl,
|
||||
imageKey,
|
||||
hideImage,
|
||||
imageHeight,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
maxWidth?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxWidth: undefined,
|
||||
});
|
||||
|
||||
const { $vuetify } = useNuxtApp();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { user } = usePageUser();
|
||||
|
||||
const recipeHousehold = ref<HouseholdSummary>();
|
||||
if (user) {
|
||||
const userApi = useUserApi();
|
||||
userApi.households.getOne(props.recipe.householdId).then(({ data }) => {
|
||||
recipeHousehold.value = data || undefined;
|
||||
});
|
||||
}
|
||||
|
||||
const hideImage = ref(false);
|
||||
const imageHeight = computed(() => {
|
||||
return $vuetify.display.xs.value ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useLoggedInState } from "~/composables/use-logged-in-state";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { useToolStore } from "~/composables/store";
|
||||
|
@ -48,71 +48,52 @@ interface RecipeToolWithOnHand extends RecipeTool {
|
|||
onHand: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeIngredients,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
isCookMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
scale: number;
|
||||
isCookMode?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isCookMode: false,
|
||||
});
|
||||
|
||||
const toolStore = isOwnGroup.value ? useToolStore() : null;
|
||||
const { user } = usePageUser();
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
const { isOwnGroup } = useLoggedInState();
|
||||
|
||||
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) => {
|
||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||
});
|
||||
}
|
||||
const toolStore = isOwnGroup.value ? useToolStore() : null;
|
||||
const { user } = usePageUser();
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
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) => {
|
||||
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false;
|
||||
return { ...tool, onHand } as RecipeToolWithOnHand;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function updateTool(index: number) {
|
||||
if (user.id && user.householdSlug && toolStore) {
|
||||
const tool = recipeTools.value[index];
|
||||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
if (!tool.householdsWithTool) {
|
||||
tool.householdsWithTool = [user.householdSlug];
|
||||
}
|
||||
else {
|
||||
tool.householdsWithTool.push(user.householdSlug);
|
||||
}
|
||||
}
|
||||
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
||||
}
|
||||
|
||||
toolStore.actions.updateOne(tool);
|
||||
function updateTool(index: number) {
|
||||
if (user.id && user.householdSlug && toolStore) {
|
||||
const tool = recipeTools.value[index];
|
||||
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
if (!tool.householdsWithTool) {
|
||||
tool.householdsWithTool = [user.householdSlug];
|
||||
}
|
||||
else {
|
||||
console.log("no user, skipping server update");
|
||||
tool.householdsWithTool.push(user.householdSlug);
|
||||
}
|
||||
}
|
||||
else if (!tool.onHand && tool.householdsWithTool?.includes(user.householdSlug)) {
|
||||
tool.householdsWithTool = tool.householdsWithTool.filter(household => household !== user.householdSlug);
|
||||
}
|
||||
|
||||
return {
|
||||
toolStore,
|
||||
recipeTools,
|
||||
isEditMode,
|
||||
updateTool,
|
||||
};
|
||||
},
|
||||
});
|
||||
toolStore.actions.updateOne(tool);
|
||||
}
|
||||
else {
|
||||
console.log("no user, skipping server update");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -2,55 +2,26 @@
|
|||
<div class="d-flex justify-space-between align-center pt-2 pb-3">
|
||||
<RecipeScaleEditButton
|
||||
v-if="!isEditMode"
|
||||
v-model.number="scaleValue"
|
||||
v-model.number="scale"
|
||||
:recipe-servings="recipeServings"
|
||||
:edit-scale="!recipe.settings.disableAmount && !isEditMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
|
||||
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 defineNuxtComponent({
|
||||
components: {
|
||||
RecipeScaleEditButton,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
emits: ["update:scale"],
|
||||
setup(props, { emit }) {
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
const props = defineProps<{ recipe: NoUndefinedField<Recipe> }>();
|
||||
|
||||
const recipeServings = computed<number>(() => {
|
||||
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
|
||||
});
|
||||
const scale = defineModel<number>({ default: 1 });
|
||||
|
||||
const scaleValue = computed<number>({
|
||||
get() {
|
||||
return props.scale;
|
||||
},
|
||||
set(val) {
|
||||
emit("update:scale", val);
|
||||
},
|
||||
});
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
return {
|
||||
recipeServings,
|
||||
scaleValue,
|
||||
isEditMode,
|
||||
};
|
||||
},
|
||||
const recipeServings = computed<number>(() => {
|
||||
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -8,24 +8,17 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||
import type { Recipe } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipePrintView,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
required: true,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
interface Props {
|
||||
recipe: Recipe;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -166,7 +166,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "dompurify";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
|
@ -188,167 +188,141 @@ type InstructionSection = {
|
|||
instructions: RecipeStep[];
|
||||
};
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
RecipeTimeCard,
|
||||
},
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => NoUndefinedField<Recipe>,
|
||||
required: true,
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
dense: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const i18n = useI18n();
|
||||
const preferences = useUserPrintPreferences();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { labels } = useNutritionLabels();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
const servingsDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay || props.recipe.recipeYield
|
||||
? 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);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
|
||||
});
|
||||
|
||||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
}
|
||||
else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
// Group ingredients by section so we can style them independently
|
||||
const ingredientSections = computed<IngredientSection[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
||||
// if title append new section to the end of the array
|
||||
if (ingredient.title) {
|
||||
sections.push({
|
||||
sectionName: ingredient.title,
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append new section if first
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add ingredient to last section in the array
|
||||
sections[sections.length - 1].ingredients.push(ingredient);
|
||||
return sections;
|
||||
}, [] as IngredientSection[]);
|
||||
});
|
||||
|
||||
// Group instructions by section so we can style them independently
|
||||
const instructionSections = computed<InstructionSection[]>(() => {
|
||||
if (!props.recipe.recipeInstructions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeInstructions.reduce((sections, step) => {
|
||||
const offset = (() => {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastOffset = sections[sections.length - 1].stepOffset;
|
||||
const lastNumSteps = sections[sections.length - 1].instructions.length;
|
||||
return lastOffset + lastNumSteps;
|
||||
})();
|
||||
|
||||
// if title append new section to the end of the array
|
||||
if (step.title) {
|
||||
sections.push({
|
||||
sectionName: step.title,
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append if first element
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add step to last section in the array
|
||||
sections[sections.length - 1].instructions.push(step);
|
||||
return sections;
|
||||
}, [] as InstructionSection[]);
|
||||
});
|
||||
|
||||
const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false, props.scale);
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
hasNotes,
|
||||
imageKey,
|
||||
ImagePosition,
|
||||
parseText,
|
||||
parseIngredientText,
|
||||
preferences,
|
||||
recipeImageUrl,
|
||||
recipeYield,
|
||||
ingredientSections,
|
||||
instructionSections,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe: NoUndefinedField<Recipe>;
|
||||
scale?: number;
|
||||
dense?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
dense: false,
|
||||
});
|
||||
|
||||
const i18n = useI18n();
|
||||
const preferences = useUserPrintPreferences();
|
||||
const { recipeImage } = useStaticRoutes();
|
||||
const { imageKey } = usePageState(props.recipe.slug);
|
||||
const { labels } = useNutritionLabels();
|
||||
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
const servingsDisplay = computed(() => {
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.recipe.recipeYieldQuantity, props.scale);
|
||||
return scaledAmountDisplay || props.recipe.recipeYield
|
||||
? 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);
|
||||
return scaledAmountDisplay ? i18n.t("recipe.serves-amount", { amount: scaledAmountDisplay }) as string : "";
|
||||
});
|
||||
|
||||
const recipeYield = computed(() => {
|
||||
if (servingsDisplay.value && yieldDisplay.value) {
|
||||
return sanitizeHTML(`${yieldDisplay.value}; ${servingsDisplay.value}`);
|
||||
}
|
||||
else {
|
||||
return sanitizeHTML(yieldDisplay.value || servingsDisplay.value);
|
||||
}
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
// Group ingredients by section so we can style them independently
|
||||
const ingredientSections = computed<IngredientSection[]>(() => {
|
||||
if (!props.recipe.recipeIngredient) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeIngredient.reduce((sections, ingredient) => {
|
||||
// if title append new section to the end of the array
|
||||
if (ingredient.title) {
|
||||
sections.push({
|
||||
sectionName: ingredient.title,
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append new section if first
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
ingredients: [ingredient],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add ingredient to last section in the array
|
||||
sections[sections.length - 1].ingredients.push(ingredient);
|
||||
return sections;
|
||||
}, [] as IngredientSection[]);
|
||||
});
|
||||
|
||||
// Group instructions by section so we can style them independently
|
||||
const instructionSections = computed<InstructionSection[]>(() => {
|
||||
if (!props.recipe.recipeInstructions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.recipe.recipeInstructions.reduce((sections, step) => {
|
||||
const offset = (() => {
|
||||
if (sections.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastOffset = sections[sections.length - 1].stepOffset;
|
||||
const lastNumSteps = sections[sections.length - 1].instructions.length;
|
||||
return lastOffset + lastNumSteps;
|
||||
})();
|
||||
|
||||
// if title append new section to the end of the array
|
||||
if (step.title) {
|
||||
sections.push({
|
||||
sectionName: step.title,
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// append if first element
|
||||
if (sections.length === 0) {
|
||||
sections.push({
|
||||
sectionName: "",
|
||||
stepOffset: offset,
|
||||
instructions: [step],
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
// otherwise add step to last section in the array
|
||||
sections[sections.length - 1].instructions.push(step);
|
||||
return sections;
|
||||
}, [] as InstructionSection[]);
|
||||
});
|
||||
|
||||
const hasNotes = computed(() => {
|
||||
return props.recipe.notes && props.recipe.notes.length > 0;
|
||||
});
|
||||
|
||||
function parseText(ingredient: RecipeIngredient) {
|
||||
return parseIngredientText(ingredient, props.recipe.settings?.disableAmount || false, props.scale);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
nudge-top="6"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-tooltip
|
||||
v-if="canEditScale"
|
||||
size="small"
|
||||
|
@ -23,7 +23,7 @@
|
|||
dark
|
||||
color="secondary-darken-1"
|
||||
size="small"
|
||||
v-bind="{ ...props, ...tooltipProps }"
|
||||
v-bind="{ ...activatorProps, ...tooltipProps }"
|
||||
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||
>
|
||||
<v-icon
|
||||
|
@ -45,7 +45,7 @@
|
|||
dark
|
||||
color="secondary-darken-1"
|
||||
size="small"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
:style="{ cursor: canEditScale ? '' : 'default' }"
|
||||
>
|
||||
<v-icon
|
||||
|
@ -77,9 +77,9 @@
|
|||
location="end"
|
||||
color="secondary-darken-1"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: resetTooltipProps }">
|
||||
<v-btn
|
||||
v-bind="props"
|
||||
v-bind="resetTooltipProps"
|
||||
icon
|
||||
flat
|
||||
class="mx-1"
|
||||
|
@ -122,76 +122,50 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
recipeServings: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
editScale: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
setup(props, { emit }) {
|
||||
const i18n = useI18n();
|
||||
const menu = ref<boolean>(false);
|
||||
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||
interface Props {
|
||||
recipeServings?: number;
|
||||
editScale?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipeServings: 0,
|
||||
editScale: false,
|
||||
});
|
||||
|
||||
const scale = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => {
|
||||
const newScaleNumber = parseFloat(`${value}`);
|
||||
emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
|
||||
},
|
||||
});
|
||||
const scale = defineModel<number>({ required: true });
|
||||
|
||||
function recalculateScale(newYield: number) {
|
||||
if (isNaN(newYield) || newYield <= 0) {
|
||||
return;
|
||||
}
|
||||
const i18n = useI18n();
|
||||
const menu = ref<boolean>(false);
|
||||
const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
|
||||
|
||||
if (props.recipeServings <= 0) {
|
||||
scale.value = 1;
|
||||
}
|
||||
else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
function recalculateScale(newYield: number) {
|
||||
if (isNaN(newYield) || newYield <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recipeYieldAmount = computed(() => {
|
||||
return useScaledAmount(props.recipeServings, scale.value);
|
||||
});
|
||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||
const yieldDisplay = computed(() => {
|
||||
return yieldQuantity.value
|
||||
? i18n.t(
|
||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
|
||||
) as string
|
||||
: "";
|
||||
});
|
||||
if (props.recipeServings <= 0) {
|
||||
scale.value = 1;
|
||||
}
|
||||
else {
|
||||
scale.value = newYield / props.recipeServings;
|
||||
}
|
||||
}
|
||||
|
||||
const disableDecrement = computed(() => {
|
||||
return yieldQuantity.value <= 1;
|
||||
});
|
||||
const recipeYieldAmount = computed(() => {
|
||||
return useScaledAmount(props.recipeServings, scale.value);
|
||||
});
|
||||
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount);
|
||||
const yieldDisplay = computed(() => {
|
||||
return yieldQuantity.value
|
||||
? i18n.t(
|
||||
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
|
||||
) as string
|
||||
: "";
|
||||
});
|
||||
|
||||
return {
|
||||
menu,
|
||||
canEditScale,
|
||||
scale,
|
||||
recalculateScale,
|
||||
yieldDisplay,
|
||||
yieldQuantity,
|
||||
disableDecrement,
|
||||
};
|
||||
},
|
||||
const disableDecrement = computed(() => {
|
||||
return yieldQuantity.value <= 1;
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -1,54 +0,0 @@
|
|||
<template>
|
||||
<div class="d-flex justify-center align-center">
|
||||
<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 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="force" @change="emitMulti">
|
||||
<v-btn size="small" :value="false" class="text-uppercase">
|
||||
{{ $t("search.and") }}
|
||||
</v-btn>
|
||||
<v-btn size="small" :value="true" class="text-uppercase">
|
||||
{{ $t("search.or") }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
type SelectionValue = "include" | "exclude" | "any";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String as () => SelectionValue,
|
||||
default: "include",
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "update"],
|
||||
data() {
|
||||
return {
|
||||
selected: false,
|
||||
match: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
emitChange() {
|
||||
this.$emit("update:modelValue", this.selected);
|
||||
},
|
||||
emitMulti() {
|
||||
const updateData = {
|
||||
exclude: this.selected,
|
||||
matchAny: this.match,
|
||||
};
|
||||
this.$emit("update", updateData);
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
|
@ -14,9 +14,7 @@
|
|||
<div v-for="(organizer, idx) in missingOrganizers" :key="idx">
|
||||
<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-icon class="ma-0 pa-0" />
|
||||
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
|
||||
{{ $t("recipe-finder.missing") }}:
|
||||
</v-card-text>
|
||||
|
@ -41,7 +39,7 @@
|
|||
</v-container>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
|
||||
|
||||
|
@ -51,71 +49,72 @@ interface Organizer {
|
|||
selected: boolean;
|
||||
}
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeCardMobile },
|
||||
props: {
|
||||
recipe: {
|
||||
type: Object as () => RecipeSummary,
|
||||
required: true,
|
||||
},
|
||||
missingFoods: {
|
||||
type: Array as () => IngredientFood[] | null,
|
||||
default: null,
|
||||
},
|
||||
missingTools: {
|
||||
type: Array as () => RecipeTool[] | null,
|
||||
default: null,
|
||||
},
|
||||
disableCheckbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
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);
|
||||
})
|
||||
: [],
|
||||
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);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: RecipeTool) => item.name,
|
||||
},
|
||||
]);
|
||||
|
||||
function handleCheckbox(organizer: Organizer) {
|
||||
if (props.disableCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
organizer.selected = !organizer.selected;
|
||||
if (organizer.selected) {
|
||||
context.emit(`add-${organizer.type}`, organizer.item);
|
||||
}
|
||||
else {
|
||||
context.emit(`remove-${organizer.type}`, organizer.item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
missingOrganizers,
|
||||
handleCheckbox,
|
||||
};
|
||||
},
|
||||
interface Props {
|
||||
recipe: RecipeSummary;
|
||||
missingFoods?: IngredientFood[] | null;
|
||||
missingTools?: RecipeTool[] | null;
|
||||
disableCheckbox?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
missingFoods: null,
|
||||
missingTools: null,
|
||||
disableCheckbox: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"add-food": [food: IngredientFood];
|
||||
"remove-food": [food: IngredientFood];
|
||||
"add-tool": [tool: RecipeTool];
|
||||
"remove-tool": [tool: RecipeTool];
|
||||
}>();
|
||||
|
||||
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);
|
||||
})
|
||||
: [],
|
||||
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);
|
||||
})
|
||||
: [],
|
||||
getLabel: (item: RecipeTool) => item.name,
|
||||
},
|
||||
]);
|
||||
|
||||
function handleCheckbox(organizer: Organizer) {
|
||||
if (props.disableCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
organizer.selected = !organizer.selected;
|
||||
if (organizer.selected) {
|
||||
if (organizer.type === "food") {
|
||||
emit("add-food", organizer.item as IngredientFood);
|
||||
}
|
||||
else {
|
||||
emit("add-tool", organizer.item as RecipeTool);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (organizer.type === "food") {
|
||||
emit("remove-food", organizer.item as IngredientFood);
|
||||
}
|
||||
else {
|
||||
emit("remove-tool", organizer.item as RecipeTool);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<template v-if="showCards">
|
||||
<template v-if="_showCards">
|
||||
<div class="text-center">
|
||||
<!-- Total Time -->
|
||||
<div
|
||||
|
@ -78,65 +78,46 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
prepTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
totalTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
performTime: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent",
|
||||
},
|
||||
small: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const i18n = useI18n();
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
prepTime?: string | null;
|
||||
totalTime?: string | null;
|
||||
performTime?: string | null;
|
||||
color?: string;
|
||||
small?: boolean;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
prepTime: null,
|
||||
totalTime: null,
|
||||
performTime: null,
|
||||
color: "accent custom-transparent",
|
||||
small: false,
|
||||
});
|
||||
|
||||
function isEmpty(str: string | null) {
|
||||
return !str || str.length === 0;
|
||||
}
|
||||
const i18n = useI18n();
|
||||
|
||||
const showCards = computed(() => {
|
||||
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
|
||||
});
|
||||
function isEmpty(str: string | null) {
|
||||
return !str || str.length === 0;
|
||||
}
|
||||
|
||||
const validateTotalTime = computed(() => {
|
||||
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
|
||||
});
|
||||
const _showCards = computed(() => {
|
||||
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
|
||||
});
|
||||
|
||||
const validatePrepTime = computed(() => {
|
||||
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
|
||||
});
|
||||
const validateTotalTime = computed(() => {
|
||||
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
|
||||
});
|
||||
|
||||
const validatePerformTime = computed(() => {
|
||||
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
|
||||
});
|
||||
const validatePrepTime = computed(() => {
|
||||
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
|
||||
});
|
||||
|
||||
const fontSize = computed(() => {
|
||||
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
|
||||
});
|
||||
const validatePerformTime = computed(() => {
|
||||
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
|
||||
});
|
||||
|
||||
return {
|
||||
showCards,
|
||||
validateTotalTime,
|
||||
validatePrepTime,
|
||||
validatePerformTime,
|
||||
fontSize,
|
||||
};
|
||||
},
|
||||
const fontSize = computed(() => {
|
||||
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
nudge-bottom="3"
|
||||
:close-on-content-click="false"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-badge
|
||||
:content="filterBadgeCount"
|
||||
:model-value="filterBadgeCount > 0"
|
||||
|
@ -21,7 +21,7 @@
|
|||
class="rounded-circle"
|
||||
size="small"
|
||||
color="info"
|
||||
v-bind="props"
|
||||
v-bind="activatorProps"
|
||||
icon
|
||||
>
|
||||
<v-icon> {{ $globals.icons.filter }} </v-icon>
|
||||
|
@ -105,7 +105,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import { useThrottleFn, whenever } from "@vueuse/core";
|
||||
import RecipeTimelineItem from "./RecipeTimelineItem.vue";
|
||||
import { useTimelinePreferences } from "~/composables/use-users/preferences";
|
||||
|
@ -115,252 +115,208 @@ import { alert } from "~/composables/use-toast";
|
|||
import { useUserApi } from "~/composables/api";
|
||||
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeTimelineItem },
|
||||
interface Props {
|
||||
modelValue?: boolean;
|
||||
queryFilter: string;
|
||||
maxHeight?: number | string;
|
||||
showRecipeCards?: boolean;
|
||||
}
|
||||
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
queryFilter: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
maxHeight: {
|
||||
type: [Number, String],
|
||||
default: undefined,
|
||||
},
|
||||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
maxHeight: undefined,
|
||||
showRecipeCards: false,
|
||||
});
|
||||
|
||||
const api = useUserApi();
|
||||
const i18n = useI18n();
|
||||
const preferences = useTimelinePreferences();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const loading = ref(true);
|
||||
const ready = ref(false);
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
const hasMore = ref(true);
|
||||
|
||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||
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 {
|
||||
...option,
|
||||
checked: preferences.value.types.includes(option.value),
|
||||
};
|
||||
});
|
||||
});
|
||||
const screenBuffer = 4;
|
||||
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initializeTimelineEvents();
|
||||
},
|
||||
);
|
||||
|
||||
setup(props) {
|
||||
const api = useUserApi();
|
||||
const i18n = useI18n();
|
||||
const preferences = useTimelinePreferences();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const loading = ref(true);
|
||||
const ready = ref(false);
|
||||
// Preferences
|
||||
function reverseSort() {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const page = ref(1);
|
||||
const perPage = 32;
|
||||
const hasMore = ref(true);
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||
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 {
|
||||
...option,
|
||||
checked: preferences.value.types.includes(option.value),
|
||||
};
|
||||
});
|
||||
});
|
||||
function toggleEventTypeOption(option: TimelineEventType) {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
interface ScrollEvent extends Event {
|
||||
target: HTMLInputElement;
|
||||
const index = preferences.value.types.indexOf(option);
|
||||
if (index === -1) {
|
||||
preferences.value.types.push(option);
|
||||
}
|
||||
else {
|
||||
preferences.value.types.splice(index, 1);
|
||||
}
|
||||
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
// Timeline Actions
|
||||
async function updateTimelineEvent(index: number) {
|
||||
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;
|
||||
}
|
||||
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
}
|
||||
|
||||
async function deleteTimelineEvent(index: number) {
|
||||
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
timelineEvents.value.splice(index, 1);
|
||||
alert.success(i18n.t("events.event-deleted") as string);
|
||||
}
|
||||
|
||||
async function getRecipes(recipeIds: string[]): Promise<Recipe[]> {
|
||||
const qf = "id IN [" + recipeIds.map(id => `"${id}"`).join(", ") + "]";
|
||||
const { data } = await api.recipes.getAll(1, -1, { queryFilter: qf });
|
||||
return data?.items || [];
|
||||
}
|
||||
|
||||
async function updateRecipes(events: RecipeTimelineEventOut[]) {
|
||||
const recipeIds: string[] = [];
|
||||
events.forEach((event) => {
|
||||
if (recipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const screenBuffer = 4;
|
||||
const onScroll = (event: ScrollEvent) => {
|
||||
if (!event.target) {
|
||||
return;
|
||||
recipeIds.push(event.recipeId);
|
||||
});
|
||||
|
||||
const results = await getRecipes(recipeIds);
|
||||
results.forEach((result) => {
|
||||
if (!result?.id) {
|
||||
return;
|
||||
}
|
||||
recipes.set(result.id, result);
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollTimelineEvents() {
|
||||
const orderBy = "timestamp";
|
||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||
|
||||
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;
|
||||
if (!response?.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = response.data.items;
|
||||
if (events.length < perPage) {
|
||||
hasMore.value = false;
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// fetch recipes
|
||||
if (props.showRecipeCards) {
|
||||
await updateRecipes(events);
|
||||
}
|
||||
|
||||
// this is set last so Vue knows to re-render
|
||||
timelineEvents.value.push(...events);
|
||||
}
|
||||
|
||||
async function initializeTimelineEvents() {
|
||||
loading.value = true;
|
||||
ready.value = false;
|
||||
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
timelineEvents.value = [];
|
||||
await scrollTimelineEvents();
|
||||
|
||||
ready.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
await scrollTimelineEvents();
|
||||
loading.value = false;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
// preload events
|
||||
initializeTimelineEvents();
|
||||
|
||||
onMounted(
|
||||
() => {
|
||||
document.onscroll = () => {
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
if (scrollHeight > clientHeight) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const { scrollTop, offsetHeight, scrollHeight } = event.target;
|
||||
|
||||
// trigger when the user is getting close to the bottom
|
||||
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer);
|
||||
if (bottomOfElement) {
|
||||
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
|
||||
if (bottomOfWindow) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
|
||||
whenever(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
initializeTimelineEvents();
|
||||
},
|
||||
);
|
||||
|
||||
// Preferences
|
||||
function reverseSort() {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
function toggleEventTypeOption(option: TimelineEventType) {
|
||||
if (loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = preferences.value.types.indexOf(option);
|
||||
if (index === -1) {
|
||||
preferences.value.types.push(option);
|
||||
}
|
||||
else {
|
||||
preferences.value.types.splice(index, 1);
|
||||
}
|
||||
|
||||
initializeTimelineEvents();
|
||||
}
|
||||
|
||||
// Timeline Actions
|
||||
async function updateTimelineEvent(index: number) {
|
||||
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;
|
||||
}
|
||||
|
||||
alert.success(i18n.t("events.event-updated") as string);
|
||||
};
|
||||
|
||||
async function deleteTimelineEvent(index: number) {
|
||||
const { response } = await api.recipes.deleteTimelineEvent(timelineEvents.value[index].id);
|
||||
if (response?.status !== 200) {
|
||||
alert.error(i18n.t("events.something-went-wrong") as string);
|
||||
return;
|
||||
}
|
||||
|
||||
timelineEvents.value.splice(index, 1);
|
||||
alert.success(i18n.t("events.event-deleted") as string);
|
||||
};
|
||||
|
||||
async function getRecipes(recipeIds: string[]): Promise<Recipe[]> {
|
||||
const qf = "id IN [" + recipeIds.map(id => `"${id}"`).join(", ") + "]";
|
||||
const { data } = await api.recipes.getAll(1, -1, { queryFilter: qf });
|
||||
return data?.items || [];
|
||||
};
|
||||
|
||||
async function updateRecipes(events: RecipeTimelineEventOut[]) {
|
||||
const recipeIds: string[] = [];
|
||||
events.forEach((event) => {
|
||||
if (recipeIds.includes(event.recipeId) || recipes.has(event.recipeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
recipeIds.push(event.recipeId);
|
||||
});
|
||||
|
||||
const results = await getRecipes(recipeIds);
|
||||
results.forEach((result) => {
|
||||
if (!result?.id) {
|
||||
return;
|
||||
}
|
||||
recipes.set(result.id, result);
|
||||
});
|
||||
}
|
||||
|
||||
async function scrollTimelineEvents() {
|
||||
const orderBy = "timestamp";
|
||||
const orderDirection = preferences.value.orderDirection === "asc" ? "asc" : "desc";
|
||||
|
||||
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;
|
||||
if (!response?.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const events = response.data.items;
|
||||
if (events.length < perPage) {
|
||||
hasMore.value = false;
|
||||
if (!events.length) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// fetch recipes
|
||||
if (props.showRecipeCards) {
|
||||
await updateRecipes(events);
|
||||
}
|
||||
|
||||
// this is set last so Vue knows to re-render
|
||||
timelineEvents.value.push(...events);
|
||||
};
|
||||
|
||||
async function initializeTimelineEvents() {
|
||||
loading.value = true;
|
||||
ready.value = false;
|
||||
|
||||
page.value = 1;
|
||||
hasMore.value = true;
|
||||
timelineEvents.value = [];
|
||||
await scrollTimelineEvents();
|
||||
|
||||
ready.value = true;
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
const infiniteScroll = useThrottleFn(() => {
|
||||
useAsyncData(useAsyncKey(), async () => {
|
||||
if (!hasMore.value || loading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
await scrollTimelineEvents();
|
||||
loading.value = false;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
// preload events
|
||||
initializeTimelineEvents();
|
||||
|
||||
onMounted(
|
||||
() => {
|
||||
document.onscroll = () => {
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
if (scrollHeight > clientHeight) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
|
||||
if (bottomOfWindow) {
|
||||
infiniteScroll();
|
||||
}
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
deleteTimelineEvent,
|
||||
filterBadgeCount,
|
||||
loading,
|
||||
onScroll,
|
||||
preferences,
|
||||
eventTypeFilterState,
|
||||
recipes,
|
||||
reverseSort,
|
||||
toggleEventTypeOption,
|
||||
timelineEvents,
|
||||
updateTimelineEvent,
|
||||
};
|
||||
},
|
||||
});
|
||||
);
|
||||
</script>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
nudge-right="50"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-btn
|
||||
icon
|
||||
:variant="buttonStyle ? 'flat' : undefined"
|
||||
|
@ -12,7 +12,7 @@
|
|||
size="small"
|
||||
:color="buttonStyle ? 'info' : 'secondary'"
|
||||
:fab="buttonStyle"
|
||||
v-bind="{ ...props, ...$attrs }"
|
||||
v-bind="{ ...activatorProps, ...$attrs }"
|
||||
@click.prevent="toggleTimeline"
|
||||
>
|
||||
<v-icon
|
||||
|
@ -39,48 +39,37 @@
|
|||
</v-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeTimeline from "./RecipeTimeline.vue";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: { RecipeTimeline },
|
||||
interface Props {
|
||||
buttonStyle?: boolean;
|
||||
slug?: string;
|
||||
recipeName?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
buttonStyle: false,
|
||||
slug: "",
|
||||
recipeName: "",
|
||||
});
|
||||
|
||||
props: {
|
||||
buttonStyle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
recipeName: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
const i18n = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const showTimeline = ref(false);
|
||||
|
||||
setup(props) {
|
||||
const i18n = useI18n();
|
||||
const { smAndDown } = useDisplay();
|
||||
const showTimeline = ref(false);
|
||||
function toggleTimeline() {
|
||||
showTimeline.value = !showTimeline.value;
|
||||
}
|
||||
function toggleTimeline() {
|
||||
showTimeline.value = !showTimeline.value;
|
||||
}
|
||||
|
||||
const timelineAttrs = computed(() => {
|
||||
let title = i18n.t("recipe.timeline");
|
||||
if (smAndDown.value) {
|
||||
title += ` – ${props.recipeName}`;
|
||||
}
|
||||
const timelineAttrs = computed(() => {
|
||||
let title = i18n.t("recipe.timeline");
|
||||
if (smAndDown.value) {
|
||||
title += ` – ${props.recipeName}`;
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
queryFilter: `recipe.slug="${props.slug}"`,
|
||||
};
|
||||
});
|
||||
|
||||
return { showTimeline, timelineAttrs, toggleTimeline };
|
||||
},
|
||||
return {
|
||||
title,
|
||||
queryFilter: `recipe.slug="${props.slug}"`,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
</v-timeline-item>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import RecipeCardMobile from "./RecipeCardMobile.vue";
|
||||
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
|
||||
import { useStaticRoutes } from "~/composables/api";
|
||||
|
@ -100,96 +100,79 @@ 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 defineNuxtComponent({
|
||||
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown },
|
||||
interface Props {
|
||||
event: RecipeTimelineEventOut;
|
||||
recipe?: Recipe;
|
||||
showRecipeCards?: boolean;
|
||||
}
|
||||
|
||||
props: {
|
||||
event: {
|
||||
type: Object as () => RecipeTimelineEventOut,
|
||||
required: true,
|
||||
},
|
||||
recipe: {
|
||||
type: Object as () => Recipe,
|
||||
default: undefined,
|
||||
},
|
||||
showRecipeCards: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["selected", "update", "delete"],
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
recipe: undefined,
|
||||
showRecipeCards: false,
|
||||
});
|
||||
|
||||
setup(props) {
|
||||
const { $vuetify, $globals } = useNuxtApp();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
const timelineEvents = ref([] as RecipeTimelineEventOut[]);
|
||||
defineEmits<{
|
||||
selected: [];
|
||||
update: [];
|
||||
delete: [];
|
||||
}>();
|
||||
|
||||
const { user: currentUser } = useMealieAuth();
|
||||
const { $vuetify, $globals } = useNuxtApp();
|
||||
const { recipeTimelineEventImage } = useStaticRoutes();
|
||||
const { eventTypeOptions } = useTimelineEventTypes();
|
||||
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
|
||||
const { user: currentUser } = useMealieAuth();
|
||||
|
||||
const useMobileFormat = computed(() => {
|
||||
return $vuetify.display.smAndDown.value;
|
||||
});
|
||||
const route = useRoute();
|
||||
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
|
||||
|
||||
const attrs = computed(() => {
|
||||
if (useMobileFormat.value) {
|
||||
return {
|
||||
class: "px-0",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "30px",
|
||||
class: "pr-0",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "250",
|
||||
class: "my-3",
|
||||
},
|
||||
};
|
||||
}
|
||||
else {
|
||||
return {
|
||||
class: "px-3",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "42px",
|
||||
class: "",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "300",
|
||||
class: "mb-5",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
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>(() => {
|
||||
if (props.event.image !== "has image") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||
});
|
||||
const useMobileFormat = computed(() => {
|
||||
return $vuetify.display.smAndDown.value;
|
||||
});
|
||||
|
||||
const attrs = computed(() => {
|
||||
if (useMobileFormat.value) {
|
||||
return {
|
||||
attrs,
|
||||
groupSlug,
|
||||
icon,
|
||||
eventImageUrl,
|
||||
hideImage,
|
||||
timelineEvents,
|
||||
useMobileFormat,
|
||||
currentUser,
|
||||
class: "px-0",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "30px",
|
||||
class: "pr-0",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "250",
|
||||
class: "my-3",
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
else {
|
||||
return {
|
||||
class: "px-3",
|
||||
small: false,
|
||||
avatar: {
|
||||
size: "42px",
|
||||
class: "",
|
||||
},
|
||||
image: {
|
||||
maxHeight: "300",
|
||||
class: "mb-5",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const icon = computed(() => {
|
||||
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>(() => {
|
||||
if (props.event.image !== "has image") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return recipeTimelineEventImage(props.event.recipeId, props.event.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
@ -24,56 +24,43 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
<script setup lang="ts">
|
||||
import DOMPurify from "dompurify";
|
||||
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
props: {
|
||||
yieldQuantity: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
yieldText: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
scale: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "accent custom-transparent",
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
interface Props {
|
||||
yieldQuantity?: number;
|
||||
yieldText?: string;
|
||||
scale?: number;
|
||||
color?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
yieldQuantity: 0,
|
||||
yieldText: "",
|
||||
scale: 1,
|
||||
color: "accent custom-transparent",
|
||||
});
|
||||
|
||||
const yieldDisplay = computed<string>(() => {
|
||||
const components: string[] = [];
|
||||
function sanitizeHTML(rawHtml: string) {
|
||||
return DOMPurify.sanitize(rawHtml, {
|
||||
USE_PROFILES: { html: true },
|
||||
ALLOWED_TAGS: ["strong", "sup"],
|
||||
});
|
||||
}
|
||||
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
if (scaledAmountDisplay) {
|
||||
components.push(scaledAmountDisplay);
|
||||
}
|
||||
const yieldDisplay = computed<string>(() => {
|
||||
const components: string[] = [];
|
||||
|
||||
const text = props.yieldText;
|
||||
if (text) {
|
||||
components.push(text);
|
||||
}
|
||||
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
|
||||
if (scaledAmountDisplay) {
|
||||
components.push(scaledAmountDisplay);
|
||||
}
|
||||
|
||||
return sanitizeHTML(components.join(" "));
|
||||
});
|
||||
const text = props.yieldText;
|
||||
if (text) {
|
||||
components.push(text);
|
||||
}
|
||||
|
||||
return {
|
||||
yieldDisplay,
|
||||
};
|
||||
},
|
||||
return sanitizeHTML(components.join(" "));
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -40,7 +40,6 @@
|
|||
v-if="requireAll != undefined"
|
||||
v-model="requireAllValue"
|
||||
density="compact"
|
||||
size="small"
|
||||
hide-details
|
||||
class="my-auto"
|
||||
color="primary"
|
||||
|
|
|
@ -93,7 +93,7 @@
|
|||
<!-- Alias Sub-Dialog -->
|
||||
<RecipeDataAliasManagerDialog
|
||||
v-if="editTarget"
|
||||
:value="aliasManagerDialog"
|
||||
v-model="aliasManagerDialog"
|
||||
:data="editTarget"
|
||||
can-submit
|
||||
@submit="updateUnitAlias"
|
||||
|
|
|
@ -371,6 +371,8 @@
|
|||
<v-btn
|
||||
v-if="recipe"
|
||||
icon
|
||||
flat
|
||||
class="bg-transparent"
|
||||
:disabled="isOffline"
|
||||
@click.prevent="removeRecipeReferenceToList(recipe.id!)"
|
||||
>
|
||||
|
@ -386,6 +388,8 @@
|
|||
<v-btn
|
||||
icon
|
||||
:disabled="isOffline"
|
||||
flat
|
||||
class="bg-transparent"
|
||||
@click.prevent="addRecipeReferenceToList(recipe.id!)"
|
||||
>
|
||||
<v-icon color="grey-lighten-1">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue