1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-05 05:25:26 +02:00

chore: script setup #3 - recipe components (#5849)

This commit is contained in:
Kuchenpirat 2025-07-30 20:37:02 +02:00 committed by GitHub
parent f2b6512eb1
commit f26e74f0f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 2761 additions and 3642 deletions

View file

@ -18,7 +18,7 @@
<RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" /> <RecipeTimelineBadge v-if="loggedIn" class="ml-1" color="info" button-style :slug="recipe.slug" :recipe-name="recipe.name!" />
<div v-if="loggedIn"> <div v-if="loggedIn">
<v-tooltip v-if="canEdit" location="bottom" color="info"> <v-tooltip v-if="canEdit" location="bottom" color="info">
<template #activator="{ props }"> <template #activator="{ props: tooltipProps }">
<v-btn <v-btn
icon icon
variant="flat" variant="flat"
@ -26,7 +26,7 @@
size="small" size="small"
color="info" color="info"
class="ml-1" class="ml-1"
v-bind="props" v-bind="tooltipProps"
@click="$emit('edit', true)" @click="$emit('edit', true)"
> >
<v-icon size="x-large"> <v-icon size="x-large">
@ -86,7 +86,7 @@
</v-toolbar> </v-toolbar>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeTimelineBadge from "./RecipeTimelineBadge.vue"; import RecipeTimelineBadge from "./RecipeTimelineBadge.vue";
@ -97,103 +97,75 @@ const DELETE_EVENT = "delete";
const CLOSE_EVENT = "close"; const CLOSE_EVENT = "close";
const JSON_EVENT = "json"; const JSON_EVENT = "json";
export default defineNuxtComponent({ interface Props {
components: { RecipeContextMenu, RecipeFavoriteBadge, RecipeTimelineBadge }, recipe: Recipe;
props: { slug: string;
recipe: { recipeScale?: number;
required: true, open: boolean;
type: Object as () => Recipe, name: string;
}, loggedIn?: boolean;
slug: { recipeId: string;
required: true, canEdit?: boolean;
type: String, }
}, withDefaults(defineProps<Props>(), {
recipeScale: { recipeScale: 1,
type: Number, loggedIn: false,
default: 1, canEdit: false,
},
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,
};
},
}); });
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> </script>
<style scoped> <style scoped>

View file

@ -2,11 +2,11 @@
<!-- Wrap v-hover with a div to provide a proper DOM element for the transition --> <!-- Wrap v-hover with a div to provide a proper DOM element for the transition -->
<div> <div>
<v-hover <v-hover
v-slot="{ isHovering, props }" v-slot="{ isHovering, props: hoverProps }"
:open-delay="50" :open-delay="50"
> >
<v-card <v-card
v-bind="props" v-bind="hoverProps"
:class="{ 'on-hover': isHovering }" :class="{ 'on-hover': isHovering }"
:style="{ cursor }" :style="{ cursor }"
:elevation="isHovering ? 12 : 2" :elevation="isHovering ? 12 : 2"
@ -100,7 +100,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeChips from "./RecipeChips.vue"; import RecipeChips from "./RecipeChips.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
@ -108,69 +108,41 @@ import RecipeCardImage from "./RecipeCardImage.vue";
import RecipeRating from "./RecipeRating.vue"; import RecipeRating from "./RecipeRating.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineNuxtComponent({ interface Props {
components: { RecipeFavoriteBadge, RecipeChips, RecipeContextMenu, RecipeRating, RecipeCardImage }, name: string;
props: { slug: string;
name: { description?: string | null;
type: String, rating?: number;
required: true, ratingColor?: string;
}, image?: string;
slug: { tags?: Array<any>;
type: String, recipeId: string;
required: true, imageHeight?: number;
}, }
description: { const props = withDefaults(defineProps<Props>(), {
type: String, description: null,
default: null, rating: 0,
}, ratingColor: "secondary",
rating: { image: "abc123",
type: Number, tags: () => [],
required: false, imageHeight: 200,
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,
};
},
}); });
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> </script>
<style> <style>
@ -195,6 +167,7 @@ export default defineNuxtComponent({
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 8; -webkit-line-clamp: 8;
line-clamp: 8;
overflow: hidden; overflow: hidden;
} }
</style> </style>

View file

@ -28,84 +28,60 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes } from "~/composables/api";
export default defineNuxtComponent({ interface Props {
props: { tiny?: boolean | null;
tiny: { small?: boolean | null;
type: Boolean, large?: boolean | null;
default: null, iconSize?: number | string;
}, slug?: string | null;
small: { recipeId: string;
type: Boolean, imageVersion?: string | null;
default: null, height?: number | string;
}, }
large: { const props = withDefaults(defineProps<Props>(), {
type: Boolean, tiny: null,
default: null, small: null,
}, large: null,
iconSize: { iconSize: 100,
type: [Number, String], slug: null,
default: 100, imageVersion: null,
}, height: "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,
};
},
}); });
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> </script>
<style scoped> <style scoped>

View file

@ -126,7 +126,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue"; import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
import RecipeContextMenu from "./RecipeContextMenu.vue"; import RecipeContextMenu from "./RecipeContextMenu.vue";
import RecipeCardImage from "./RecipeCardImage.vue"; import RecipeCardImage from "./RecipeCardImage.vue";
@ -134,82 +134,44 @@ import RecipeRating from "./RecipeRating.vue";
import RecipeChips from "./RecipeChips.vue"; import RecipeChips from "./RecipeChips.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
export default defineNuxtComponent({ interface Props {
components: { name: string;
RecipeFavoriteBadge, slug: string;
RecipeContextMenu, description: string;
RecipeRating, rating?: number;
RecipeCardImage, image?: string;
RecipeChips, tags?: Array<any>;
}, recipeId: string;
props: { vertical?: boolean;
name: { isFlat?: boolean;
type: String, height?: number;
required: true, disableHighlight?: boolean;
}, }
slug: { const props = withDefaults(defineProps<Props>(), {
type: String, rating: 0,
required: true, image: "abc123",
}, tags: () => [],
description: { vertical: false,
type: String, isFlat: false,
required: true, height: 150,
}, disableHighlight: false,
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,
};
},
}); });
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> </script>
<style scoped> <style scoped>

View file

@ -36,11 +36,11 @@
offset-y offset-y
start start
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-btn <v-btn
variant="text" variant="text"
:icon="$vuetify.display.xs" :icon="$vuetify.display.xs"
v-bind="props" v-bind="activatorProps"
:loading="sortLoading" :loading="sortLoading"
> >
<v-icon :start="!$vuetify.display.xs"> <v-icon :start="!$vuetify.display.xs">
@ -162,7 +162,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useThrottleFn } from "@vueuse/core"; import { useThrottleFn } from "@vueuse/core";
import RecipeCard from "./RecipeCard.vue"; import RecipeCard from "./RecipeCard.vue";
import RecipeCardMobile from "./RecipeCardMobile.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 REPLACE_RECIPES_EVENT = "replaceRecipes";
const APPEND_RECIPES_EVENT = "appendRecipes"; const APPEND_RECIPES_EVENT = "appendRecipes";
export default defineNuxtComponent({ interface Props {
components: { disableToolbar?: boolean;
RecipeCard, disableSort?: boolean;
RecipeCardMobile, icon?: string | null;
}, title?: string | null;
props: { singleColumn?: boolean;
disableToolbar: { recipes?: Recipe[];
type: Boolean, query?: RecipeSearchQuery | null;
default: false, }
}, const props = withDefaults(defineProps<Props>(), {
disableSort: { disableToolbar: false,
type: Boolean, disableSort: false,
default: false, icon: null,
}, title: null,
icon: { singleColumn: false,
type: String, recipes: () => [],
default: null, query: 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();
const EVENTS = { const emit = defineEmits<{
az: "az", replaceRecipes: [recipes: Recipe[]];
rating: "rating", appendRecipes: [recipes: Recipe[]];
created: "created", }>();
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
const $auth = useMealieAuth(); const { $vuetify } = useNuxtApp();
const { $globals } = useNuxtApp(); const preferences = useUserSortPreferences();
const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
});
const displayTitleIcon = computed(() => { const EVENTS = {
return props.icon || $globals.icons.tags; az: "az",
}); rating: "rating",
created: "created",
updated: "updated",
lastMade: "lastMade",
shuffle: "shuffle",
};
const state = reactive({ const $auth = useMealieAuth();
sortLoading: false, const { $globals } = useNuxtApp();
}); const { isOwnGroup } = useLoggedInState();
const useMobileCards = computed(() => {
return $vuetify.display.smAndDown.value || preferences.value.useMobileCards;
});
const route = useRoute(); const displayTitleIcon = computed(() => {
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || ""); return props.icon || $globals.icons.tags;
});
const page = ref(1); const sortLoading = ref(false);
const perPage = 32;
const hasMore = ref(true);
const ready = ref(false);
const loading = ref(false);
const { fetchMore, getRandom } = useLazyRecipes(isOwnGroup.value ? null : groupSlug.value); const route = useRoute();
const router = useRouter(); const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const queryFilter = computed(() => { const page = ref(1);
return props.query.queryFilter || null; 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 queryFilter = computed(() => {
// const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null; return props.query?.queryFilter || null;
// if (props.query.queryFilter && orderByFilter) { // TODO: allow user to filter out null values when ordering by a value that may be null (such as lastMade)
// return `(${props.query.queryFilter}) AND ${orderByFilter}`;
// } else if (props.query.queryFilter) {
// return props.query.queryFilter;
// } else {
// return orderByFilter;
// }
});
async function fetchRecipes(pageCount = 1) { // const orderBy = props.query?.orderBy || preferences.value.orderBy;
const orderDir = props.query?.orderDirection || preferences.value.orderDirection; // const orderByFilter = preferences.value.filterNull && orderBy ? `${orderBy} IS NOT NULL` : null;
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 () => { // 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(); await initRecipes();
ready.value = true; 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> </script>
<style> <style>

View file

@ -23,66 +23,38 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
export type UrlPrefixParam = "tags" | "categories" | "tools"; export type UrlPrefixParam = "tags" | "categories" | "tools";
export default defineNuxtComponent({ interface Props {
props: { truncate?: boolean;
truncate: { items?: RecipeCategory[] | RecipeTag[] | RecipeTool[];
type: Boolean, title?: boolean;
default: false, urlPrefix?: UrlPrefixParam;
}, limit?: number;
items: { small?: boolean;
type: Array as () => RecipeCategory[] | RecipeTag[] | RecipeTool[], maxWidth?: string | null;
default: () => [], }
}, const props = withDefaults(defineProps<Props>(), {
title: { truncate: false,
type: Boolean, items: () => [],
default: false, title: false,
}, urlPrefix: "categories",
urlPrefix: { limit: 999,
type: String as () => UrlPrefixParam, small: false,
default: "categories", maxWidth: null,
},
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,
};
},
}); });
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> </script>
<style></style> <style></style>

View file

@ -55,12 +55,12 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-text-field <v-text-field
v-model="newMealdateString" v-model="newMealdateString"
:label="$t('general.date')" :label="$t('general.date')"
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="props" v-bind="activatorProps"
readonly readonly
/> />
</template> </template>
@ -100,7 +100,7 @@
:open-on-hover="$vuetify.display.mdAndUp" :open-on-hover="$vuetify.display.mdAndUp"
content-class="d-print-none" content-class="d-print-none"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-btn <v-btn
icon icon
:variant="fab ? 'flat' : undefined" :variant="fab ? 'flat' : undefined"
@ -108,7 +108,7 @@
:size="fab ? 'small' : undefined" :size="fab ? 'small' : undefined"
:color="fab ? 'info' : 'secondary'" :color="fab ? 'info' : 'secondary'"
:fab="fab" :fab="fab"
v-bind="props" v-bind="activatorProps"
@click.prevent @click.prevent
> >
<v-icon <v-icon
@ -150,7 +150,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue"; import RecipeDialogAddToShoppingList from "./RecipeDialogAddToShoppingList.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue"; import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue"; import RecipeDialogShare from "./RecipeDialogShare.vue";
@ -186,363 +186,312 @@ export interface ContextMenuItem {
isPublic: boolean; isPublic: boolean;
} }
export default defineNuxtComponent({ interface Props {
components: { useItems?: ContextMenuIncludes;
RecipeDialogAddToShoppingList, appendItems?: ContextMenuItem[];
RecipeDialogPrintPreferences, leadingItems?: ContextMenuItem[];
RecipeDialogShare, menuTop?: boolean;
}, fab?: boolean;
props: { color?: string;
useItems: { slug: string;
type: Object as () => ContextMenuIncludes, menuIcon?: string | null;
default: () => ({ name: string;
delete: true, recipe?: Recipe;
edit: true, recipeId: string;
download: true, recipeScale?: number;
duplicate: false, }
mealplanner: true, const props = withDefaults(defineProps<Props>(), {
shoppingList: true, useItems: () => ({
print: true, delete: true,
printPreferences: true, edit: true,
share: true, download: true,
recipeActions: true, duplicate: false,
}), mealplanner: true,
}, shoppingList: true,
// Append items are added at the end of the useItems list print: true,
appendItems: { printPreferences: true,
type: Array as () => ContextMenuItem[], share: true,
default: () => [], recipeActions: true,
}, }),
// Append items are added at the beginning of the useItems list appendItems: () => [],
leadingItems: { leadingItems: () => [],
type: Array as () => ContextMenuItem[], menuTop: true,
default: () => [], fab: false,
}, color: "primary",
menuTop: { menuIcon: null,
type: Boolean, recipe: undefined,
default: true, recipeScale: 1,
},
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,
};
},
}); });
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> </script>

View file

@ -33,7 +33,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { validators } from "~/composables/use-validators"; import { validators } from "~/composables/use-validators";
import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe"; import type { IngredientFood, IngredientUnit } from "~/lib/api/types/recipe";
@ -42,86 +42,66 @@ export interface GenericAlias {
name: string; name: string;
} }
export default defineNuxtComponent({ interface Props {
props: { data: IngredientFood | IngredientUnit;
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);
},
});
function createAlias() { const props = defineProps<Props>();
aliases.value.push({
name: "",
});
}
function deleteAlias(index: number) { const emit = defineEmits<{
aliases.value.splice(index, 1); submit: [aliases: GenericAlias[]];
} cancel: [];
}>();
const aliases = ref<GenericAlias[]>(props.data.aliases || []); // V-Model Support
function initAliases() { const dialog = defineModel<boolean>({ default: false });
aliases.value = [...props.data.aliases || []];
if (!aliases.value.length) {
createAlias();
}
}
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(); initAliases();
whenever( },
() => props.modelValue, );
() => {
initAliases();
},
);
function saveAliases() { function saveAliases() {
const seenAliasNames: string[] = []; const seenAliasNames: string[] = [];
const keepAliases: GenericAlias[] = []; const keepAliases: GenericAlias[] = [];
aliases.value.forEach((alias) => { aliases.value.forEach((alias) => {
if ( if (
!alias.name !alias.name
|| alias.name === props.data.name || alias.name === props.data.name
|| alias.name === props.data.pluralName || alias.name === props.data.pluralName
|| alias.name === props.data.abbreviation || alias.name === props.data.abbreviation
|| alias.name === props.data.pluralAbbreviation || alias.name === props.data.pluralAbbreviation
|| seenAliasNames.includes(alias.name) || seenAliasNames.includes(alias.name)
) { ) {
return; return;
}
keepAliases.push(alias);
seenAliasNames.push(alias.name);
});
aliases.value = keepAliases;
context.emit("submit", keepAliases);
} }
return { keepAliases.push(alias);
aliases, seenAliasNames.push(alias.name);
createAlias, });
dialog,
deleteAlias, aliases.value = keepAliases;
saveAliases, emit("submit", keepAliases);
validators, }
};
},
});
</script> </script>

View file

@ -62,7 +62,7 @@
</v-data-table> </v-data-table>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import UserAvatar from "../User/UserAvatar.vue"; import UserAvatar from "../User/UserAvatar.vue";
import RecipeChip from "./RecipeChips.vue"; import RecipeChip from "./RecipeChips.vue";
import type { Recipe, RecipeCategory, RecipeTool } from "~/lib/api/types/recipe"; 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 { UserSummary } from "~/lib/api/types/user";
import type { RecipeTag } from "~/lib/api/types/household"; import type { RecipeTag } from "~/lib/api/types/household";
const INPUT_EVENT = "update:modelValue";
interface ShowHeaders { interface ShowHeaders {
id: boolean; id: boolean;
owner: boolean; owner: boolean;
@ -84,140 +82,114 @@ interface ShowHeaders {
dateAdded: boolean; dateAdded: boolean;
} }
export default defineNuxtComponent({ interface Props {
components: { RecipeChip, UserAvatar }, loading?: boolean;
props: { recipes?: Recipe[];
modelValue: { showHeaders?: ShowHeaders;
type: Array as PropType<Recipe[]>, }
required: false, const props = withDefaults(defineProps<Props>(), {
default: () => [], loading: false,
}, recipes: () => [],
loading: { showHeaders: () => ({
type: Boolean, id: true,
required: false, owner: false,
default: false, tags: true,
}, categories: true,
recipes: { tools: true,
type: Array as () => Recipe[], recipeServings: true,
default: () => [], recipeYieldQuantity: true,
}, recipeYield: true,
showHeaders: { dateAdded: true,
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,
};
},
}); });
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> </script>

View file

@ -51,7 +51,7 @@
<BaseDialog <BaseDialog
v-if="shoppingListIngredientDialog" v-if="shoppingListIngredientDialog"
v-model="dialog" v-model="dialog"
:title="selectedShoppingList ? selectedShoppingList.name : $t('recipe.add-to-list')" :title="selectedShoppingList?.name || $t('recipe.add-to-list')"
:icon="$globals.icons.cartCheck" :icon="$globals.icons.cartCheck"
width="70%" width="70%"
:submit-text="$t('recipe.add-to-list')" :submit-text="$t('recipe.add-to-list')"
@ -137,7 +137,7 @@
color="secondary" color="secondary"
density="compact" density="compact"
/> />
<div :key="ingredientData.ingredient.quantity"> <div :key="`${ingredientData.ingredient.quantity || 'no-qty'}-${i}`">
<RecipeIngredientListItem <RecipeIngredientListItem
:ingredient="ingredientData.ingredient" :ingredient="ingredientData.ingredient"
:disable-amount="ingredientData.disableAmount" :disable-amount="ingredientData.disableAmount"
@ -172,7 +172,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { toRefs } from "@vueuse/core"; import { toRefs } from "@vueuse/core";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
@ -203,240 +203,215 @@ export interface ShoppingListRecipeIngredientSection {
ingredientSections: ShoppingListIngredientSection[]; ingredientSections: ShoppingListIngredientSection[];
} }
export default defineNuxtComponent({ interface Props {
components: { recipes?: RecipeWithScale[];
RecipeIngredientListItem, 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 watch(dialog, (val) => {
const dialog = computed({ if (!val) {
get: () => { initState();
return props.modelValue; }
}, });
set: (val) => {
context.emit("update:modelValue", val); async function consolidateRecipesIntoSections(recipes: RecipeWithScale[]) {
initState(); 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({ let currentTitle = "";
shoppingListDialog: true, const onHandIngs: ShoppingListIngredient[] = [];
shoppingListIngredientDialog: false, const shoppingListIngredientSections = shoppingListIngredients.reduce((sections, ing) => {
shoppingListShowAllToggled: false, if (ing.ingredient.title) {
}); currentTitle = ing.ingredient.title;
}
const userHousehold = computed(() => { // If this is the first item in the section, create a new section
return $auth.user.value?.householdSlug || ""; if (sections.length === 0 || currentTitle !== sections[sections.length - 1].sectionName) {
}); if (sections.length) {
// Add the on-hand ingredients to the previous section
const shoppingListChoices = computed(() => { sections[sections.length - 1].ingredients.push(...onHandIngs);
return props.shoppingLists.filter(list => preferences.value.viewAllLists || list.userId === $auth.user.value?.id); onHandIngs.length = 0;
});
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 { sections.push({
ready.value = true; 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 { error } = await api.shopping.lists.addRecipes(selectedShoppingList.value.id, recipeData);
const recipeSectionMap = new Map<string, ShoppingListRecipeIngredientSection>(); // eslint-disable-next-line @typescript-eslint/no-unused-expressions
for (const recipe of recipes) { error ? alert.error(i18n.t("recipe.failed-to-add-recipes-to-list")) : alert.success(i18n.t("recipe.successfully-added-to-list"));
if (!recipe.slug) {
continue;
}
if (recipeSectionMap.has(recipe.slug)) { state.shoppingListDialog = false;
recipeSectionMap.get(recipe.slug).recipeScale += recipe.scale; state.shoppingListIngredientDialog = false;
continue; dialog.value = false;
} }
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,
};
},
});
</script> </script>
<style scoped lang="css"> <style scoped lang="css">

View file

@ -4,9 +4,9 @@
v-model="dialog" v-model="dialog"
width="800" width="800"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<BaseButton <BaseButton
v-bind="props" v-bind="activatorProps"
@click="inputText = inputTextProp" @click="inputText = inputTextProp"
> >
{{ $t("new-recipe.bulk-add") }} {{ $t("new-recipe.bulk-add") }}
@ -89,88 +89,75 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
export default defineNuxtComponent({ interface Props {
props: { inputTextProp?: string;
inputTextProp: { }
type: String, const props = withDefaults(defineProps<Props>(), {
required: false, inputTextProp: "",
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),
};
},
}); });
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> </script>

View file

@ -44,6 +44,7 @@
<v-switch <v-switch
v-model="preferences.showDescription" v-model="preferences.showDescription"
hide-details hide-details
color="primary"
:label="$t('recipe.description')" :label="$t('recipe.description')"
/> />
</v-row> </v-row>
@ -51,6 +52,7 @@
<v-switch <v-switch
v-model="preferences.showNotes" v-model="preferences.showNotes"
hide-details hide-details
color="primary"
:label="$t('recipe.notes')" :label="$t('recipe.notes')"
/> />
</v-row> </v-row>
@ -63,6 +65,7 @@
<v-switch <v-switch
v-model="preferences.showNutrition" v-model="preferences.showNutrition"
hide-details hide-details
color="primary"
:label="$t('recipe.nutrition')" :label="$t('recipe.nutrition')"
/> />
</v-row> </v-row>
@ -83,45 +86,19 @@
</BaseDialog> </BaseDialog>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences"; import { ImagePosition, useUserPrintPreferences } from "~/composables/use-users/preferences";
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue"; import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({ interface Props {
components: { recipe?: NoUndefinedField<Recipe>;
RecipePrintView, }
}, withDefaults(defineProps<Props>(), {
props: { recipe: undefined,
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,
};
},
}); });
const dialog = defineModel<boolean>({ default: false });
const preferences = useUserPrintPreferences();
</script> </script>

View file

@ -52,10 +52,6 @@
<div class="mr-auto"> <div class="mr-auto">
{{ $t("search.results") }} {{ $t("search.results") }}
</div> </div>
<!-- <router-link
:to="advancedSearchUrl"
class="text-primary"
> {{ $t("search.advanced-search") }} </router-link> -->
</v-card-actions> </v-card-actions>
<RecipeCardMobile <RecipeCardMobile
@ -76,7 +72,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import type { RecipeSummary } from "~/lib/api/types/recipe"; 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"; import { usePublicExploreApi } from "~/composables/api/api-client";
const SELECTED_EVENT = "selected"; const SELECTED_EVENT = "selected";
export default defineNuxtComponent({
components: {
RecipeCardMobile,
},
setup(_, context) { // Define emits
const $auth = useMealieAuth(); const emit = defineEmits<{
const state = reactive({ selected: [recipe: RecipeSummary];
loading: false, }>();
selectedIndex: -1,
});
// =========================================================================== const $auth = useMealieAuth();
// Dialog State Management const loading = ref(false);
const dialog = ref(false); const selectedIndex = ref(-1);
// Reset or Grab Recipes on Change // ===========================================================================
watch(dialog, (val) => { // Dialog State Management
if (!val) { const dialog = ref(false);
search.query.value = "";
state.selectedIndex = -1;
search.data.value = [];
}
});
// =========================================================================== // Reset or Grab Recipes on Change
// Event Handlers watch(dialog, (val) => {
if (!val) {
search.query.value = "";
selectedIndex.value = -1;
search.data.value = [];
}
});
function selectRecipe() { // ===========================================================================
const recipeCards = document.getElementsByClassName("arrow-nav"); // Event Handlers
if (recipeCards) {
if (state.selectedIndex < 0) {
state.selectedIndex = -1;
document.getElementById("arrow-search")?.focus();
return;
}
if (state.selectedIndex >= recipeCards.length) { function selectRecipe() {
state.selectedIndex = recipeCards.length - 1; const recipeCards = document.getElementsByClassName("arrow-nav");
} if (recipeCards) {
if (selectedIndex.value < 0) {
(recipeCards[state.selectedIndex] as HTMLElement).focus(); selectedIndex.value = -1;
} document.getElementById("arrow-search")?.focus();
return;
} }
function onUpDown(e: KeyboardEvent) { if (selectedIndex.value >= recipeCards.length) {
if (e.key === "Enter") { selectedIndex.value = recipeCards.length - 1;
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();
} }
watch(dialog, (val) => { (recipeCards[selectedIndex.value] as HTMLElement).focus();
if (!val) { }
document.removeEventListener("keyup", onUpDown); }
}
else {
document.addEventListener("keyup", onUpDown);
}
});
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || ""); function onUpDown(e: KeyboardEvent) {
const route = useRoute(); if (e.key === "Enter") {
const advancedSearchUrl = computed(() => `/g/${groupSlug.value}`); console.log(document.activeElement);
watch(route, close); // (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() { watch(dialog, (val) => {
dialog.value = true; if (!val) {
} document.removeEventListener("keyup", onUpDown);
function close() { }
dialog.value = false; else {
} document.addEventListener("keyup", onUpDown);
}
});
// =========================================================================== const route = useRoute();
// Basic Search const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || "");
const { isOwnGroup } = useLoggedInState(); watch(route, close);
const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
// Select Handler function open() {
dialog.value = true;
}
function close() {
dialog.value = false;
}
function handleSelect(recipe: RecipeSummary) { // ===========================================================================
close(); // Basic Search
context.emit(SELECTED_EVENT, recipe); const { isOwnGroup } = useLoggedInState();
} const api = isOwnGroup.value ? useUserApi() : usePublicExploreApi(groupSlug.value).explore;
const search = useRecipeSearch(api);
return { // Select Handler
...toRefs(state), function handleSelect(recipe: RecipeSummary) {
advancedSearchUrl, close();
dialog, emit(SELECTED_EVENT, recipe);
open, }
close,
handleSelect, // Expose functions to parent components
search, defineExpose({
}; open,
}, close,
}); });
</script> </script>

View file

@ -14,14 +14,14 @@
max-width="290px" max-width="290px"
min-width="auto" min-width="auto"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-text-field <v-text-field
v-model="expirationDateString" v-model="expirationDateString"
:label="$t('recipe-share.expiration-date')" :label="$t('recipe-share.expiration-date')"
:hint="$t('recipe-share.default-30-days')" :hint="$t('recipe-share.default-30-days')"
persistent-hint persistent-hint
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="props" v-bind="activatorProps"
readonly readonly
/> />
</template> </template>
@ -92,150 +92,116 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useClipboard, useShare, whenever } from "@vueuse/core"; import { useClipboard, useShare, whenever } from "@vueuse/core";
import type { RecipeShareToken } from "~/lib/api/types/recipe"; import type { RecipeShareToken } from "~/lib/api/types/recipe";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useHouseholdSelf } from "~/composables/use-households"; import { useHouseholdSelf } from "~/composables/use-households";
import { alert } from "~/composables/use-toast"; import { alert } from "~/composables/use-toast";
export default defineNuxtComponent({ interface Props {
props: { recipeId: string;
modelValue: { name: string;
type: Boolean, }
default: false, const props = defineProps<Props>();
},
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);
},
});
const state = reactive({ const dialog = defineModel<boolean>({ default: false });
datePickerMenu: false,
expirationDate: new Date(Date.now() - new Date().getTimezoneOffset() * 60000),
tokens: [] as RecipeShareToken[],
});
const expirationDateString = computed(() => { const datePickerMenu = ref(false);
return state.expirationDate.toISOString().substring(0, 10); const expirationDate = ref(new Date(Date.now() - new Date().getTimezoneOffset() * 60000));
}); const tokens = ref<RecipeShareToken[]>([]);
whenever( const expirationDateString = computed(() => {
() => props.modelValue, return expirationDate.value.toISOString().substring(0, 10);
() => {
// 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,
};
},
}); });
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> </script>

View file

@ -4,7 +4,7 @@
nudge-right="50" nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
> >
<template #activator="{ props }"> <template #activator="{ props: tooltipProps }">
<v-btn <v-btn
v-if="isFavorite || showAlways" v-if="isFavorite || showAlways"
icon icon
@ -13,7 +13,7 @@
size="small" size="small"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle" :fab="buttonStyle"
v-bind="{ ...props, ...$attrs }" v-bind="{ ...tooltipProps, ...$attrs }"
@click.prevent="toggleFavorite" @click.prevent="toggleFavorite"
> >
<v-icon <v-icon
@ -28,47 +28,38 @@
</v-tooltip> </v-tooltip>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useUserSelfRatings } from "~/composables/use-users"; import { useUserSelfRatings } from "~/composables/use-users";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
export default defineNuxtComponent({ interface Props {
props: { recipeId?: string;
recipeId: { showAlways?: boolean;
type: String, buttonStyle?: boolean;
default: "", }
}, const props = withDefaults(defineProps<Props>(), {
showAlways: { recipeId: "",
type: Boolean, showAlways: false,
default: false, buttonStyle: 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 };
},
}); });
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> </script>

View file

@ -7,11 +7,11 @@
nudge-top="6" nudge-top="6"
:close-on-content-click="false" :close-on-content-click="false"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-btn <v-btn
color="accent" color="accent"
dark dark
v-bind="props" v-bind="activatorProps"
> >
<v-icon start> <v-icon start>
{{ $globals.icons.fileImage }} {{ $globals.icons.fileImage }}
@ -61,52 +61,42 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
const REFRESH_EVENT = "refresh"; const REFRESH_EVENT = "refresh";
const UPLOAD_EVENT = "upload"; const UPLOAD_EVENT = "upload";
export default defineNuxtComponent({ const props = defineProps<{ slug: string }>();
props: {
slug: {
type: String,
required: true,
},
},
setup(props, context) {
const state = reactive({
url: "",
loading: false,
menu: false,
});
function uploadImage(fileObject: File) { const emit = defineEmits<{
context.emit(UPLOAD_EVENT, fileObject); refresh: [];
state.menu = false; upload: [fileObject: File];
} }>();
const api = useUserApi(); const url = ref("");
async function getImageFromURL() { const loading = ref(false);
state.loading = true; const menu = ref(false);
if (await api.recipes.updateImagebyURL(props.slug, state.url)) {
context.emit(REFRESH_EVENT);
}
state.loading = false;
state.menu = false;
}
const i18n = useI18n(); function uploadImage(fileObject: File) {
const messages = props.slug ? [""] : [i18n.t("recipe.save-recipe-before-use")]; emit(UPLOAD_EVENT, fileObject);
menu.value = false;
}
return { const api = useUserApi();
...toRefs(state), async function getImageFromURL() {
uploadImage, loading.value = true;
getImageFromURL, if (await api.recipes.updateImagebyURL(props.slug, url.value)) {
messages, 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> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View file

@ -3,21 +3,13 @@
<div v-html="safeMarkup" /> <div v-html="safeMarkup" />
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients"; import { sanitizeIngredientHTML } from "~/composables/recipes/use-recipe-ingredients";
export default defineNuxtComponent({ interface Props {
props: { markup: string;
markup: { }
type: String, const props = defineProps<Props>();
required: true,
}, const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
},
setup(props) {
const safeMarkup = computed(() => sanitizeIngredientHTML(props.markup));
return {
safeMarkup,
};
},
});
</script> </script>

View file

@ -28,34 +28,22 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import type { RecipeIngredient } from "~/lib/api/types/household"; import type { RecipeIngredient } from "~/lib/api/types/household";
import { useParsedIngredientText } from "~/composables/recipes"; import { useParsedIngredientText } from "~/composables/recipes";
export default defineNuxtComponent({ interface Props {
props: { ingredient: RecipeIngredient;
ingredient: { disableAmount?: boolean;
type: Object as () => RecipeIngredient, scale?: number;
required: true, }
}, const props = withDefaults(defineProps<Props>(), {
disableAmount: { disableAmount: false,
type: Boolean, scale: 1,
default: false, });
},
scale: {
type: Number,
default: 1,
},
},
setup(props) {
const parsedIng = computed(() => {
return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
});
return { const parsedIng = computed(() => {
parsedIng, return useParsedIngredientText(props.ingredient, props.disableAmount, props.scale);
};
},
}); });
</script> </script>

View file

@ -53,71 +53,53 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue"; import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import { parseIngredientText } from "~/composables/recipes"; import { parseIngredientText } from "~/composables/recipes";
import type { RecipeIngredient } from "~/lib/api/types/recipe"; import type { RecipeIngredient } from "~/lib/api/types/recipe";
export default defineNuxtComponent({ interface Props {
components: { RecipeIngredientListItem }, value?: RecipeIngredient[];
props: { disableAmount?: boolean;
value: { scale?: number;
type: Array as () => RecipeIngredient[], isCookMode?: boolean;
default: () => [], }
}, const props = withDefaults(defineProps<Props>(), {
disableAmount: { value: () => [],
type: Boolean, disableAmount: false,
default: false, scale: 1,
}, isCookMode: 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,
};
},
}); });
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> </script>
<style> <style>

View file

@ -30,11 +30,11 @@
offset-y offset-y
max-width="290px" max-width="290px"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-text-field <v-text-field
v-model="newTimelineEventTimestampString" v-model="newTimelineEventTimestampString"
:prepend-icon="$globals.icons.calendar" :prepend-icon="$globals.icons.calendar"
v-bind="props" v-bind="activatorProps"
readonly readonly
/> />
</template> </template>
@ -87,12 +87,12 @@
<div v-if="lastMadeReady" class="d-flex justify-center flex-wrap"> <div v-if="lastMadeReady" class="d-flex justify-center flex-wrap">
<v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger"> <v-row no-gutters class="d-flex flex-wrap align-center" style="font-size: larger">
<v-tooltip location="bottom"> <v-tooltip location="bottom">
<template #activator="{ props }"> <template #activator="{ props: tooltipProps }">
<v-btn <v-btn
rounded rounded
variant="outlined" variant="outlined"
size="x-large" size="x-large"
v-bind="props" v-bind="tooltipProps"
style="border-color: rgb(var(--v-theme-primary));" style="border-color: rgb(var(--v-theme-primary));"
@click="madeThisDialog = true" @click="madeThisDialog = true"
> >
@ -117,7 +117,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { whenever } from "@vueuse/core"; import { whenever } from "@vueuse/core";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { alert } from "~/composables/use-toast"; 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 { Recipe, RecipeTimelineEventIn, RecipeTimelineEventOut } from "~/lib/api/types/recipe";
import type { VForm } from "~/types/auto-forms"; import type { VForm } from "~/types/auto-forms";
export default defineNuxtComponent({ const props = defineProps<{ recipe: Recipe }>();
props: { const emit = defineEmits<{
recipe: { eventCreated: [event: RecipeTimelineEventOut];
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 lastMade = ref(props.recipe.lastMade); const madeThisDialog = ref(false);
const lastMadeReady = ref(false); const userApi = useUserApi();
onMounted(async () => { const { household } = useHouseholdSelf();
if (!$auth.user?.value?.householdSlug) { const i18n = useI18n();
lastMade.value = props.recipe.lastMade; const $auth = useMealieAuth();
} const domMadeThisForm = ref<VForm>();
else { const newTimelineEvent = ref<RecipeTimelineEventIn>({
const { data } = await userApi.households.getCurrentUserHouseholdRecipe(props.recipe.slug || ""); subject: "",
lastMade.value = data?.lastMade; eventType: "comment",
} eventMessage: "",
timestamp: undefined,
lastMadeReady.value = true; recipeId: props.recipe?.id || "",
});
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 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> </script>

View file

@ -51,141 +51,121 @@
</v-list> </v-list>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useFraction } from "~/composables/recipes/use-fraction"; import { useFraction } from "~/composables/recipes/use-fraction";
import type { ShoppingListItemOut } from "~/lib/api/types/household"; import type { ShoppingListItemOut } from "~/lib/api/types/household";
import type { RecipeSummary } from "~/lib/api/types/recipe"; import type { RecipeSummary } from "~/lib/api/types/recipe";
export default defineNuxtComponent({ interface Props {
props: { recipes: RecipeSummary[];
recipes: { listItem?: ShoppingListItemOut;
type: Array as () => RecipeSummary[], small?: boolean;
required: true, tile?: boolean;
}, showDescription?: boolean;
listItem: { disabled?: boolean;
type: Object as () => ShoppingListItemOut | undefined, }
default: undefined, const props = withDefaults(defineProps<Props>(), {
}, listItem: undefined,
small: { small: false,
type: Boolean, tile: false,
default: false, showDescription: false,
}, disabled: 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 || "");
const attrs = computed(() => { const $auth = useMealieAuth();
return props.small const { frac } = useFraction();
? { const route = useRoute();
class: { const groupSlug = computed(() => route.params.groupSlug || $auth.user?.value?.groupSlug || "");
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) { const attrs = computed(() => {
return DOMPurify.sanitize(rawHtml, { return props.small
USE_PROFILES: { html: true }, ? {
ALLOWED_TAGS: ["strong", "sup"], 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>&frasl;<sub>${fraction[2]}</sub>`;
}
else {
listItemDescription = (quantity).toString();
}
}
else {
listItemDescription = (Math.round(quantity * 100) / 100).toString();
} }
const listItemDescriptions = computed<string[]>(() => { if (props.listItem.unit) {
if ( const unitDisplay = props.listItem.unit.useAbbreviation && props.listItem.unit.abbreviation
props.recipes.length === 1 // we don't need to specify details if there's only one recipe ref ? props.listItem.unit.abbreviation
|| !props.listItem?.recipeReferences : props.listItem.unit.name;
|| props.listItem.recipeReferences.length !== props.recipes.length
) {
return props.recipes.map(_ => "");
}
const listItemDescriptions: string[] = []; listItemDescription += ` ${unitDisplay}`;
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 (itemRef.recipeNote) {
if (props.listItem.unit?.fraction) { listItemDescription += `, ${itemRef.recipeNote}`;
const fraction = frac(quantity, 10, true); }
if (fraction[0] !== undefined && fraction[0] > 0) {
listItemDescription += fraction[0];
}
if (fraction[1] > 0) { listItemDescriptions.push(sanitizeHTML(listItemDescription));
listItemDescription += ` <sup>${fraction[1]}</sup>&frasl;<sub>${fraction[2]}</sub>`; }
}
else {
listItemDescription = (quantity).toString();
}
}
else {
listItemDescription = (Math.round(quantity * 100) / 100).toString();
}
if (props.listItem.unit) { return listItemDescriptions;
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,
};
},
}); });
</script> </script>

View file

@ -45,62 +45,48 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useNutritionLabels } from "~/composables/recipes"; import { useNutritionLabels } from "~/composables/recipes";
import type { Nutrition } from "~/lib/api/types/recipe"; import type { Nutrition } from "~/lib/api/types/recipe";
import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition"; import type { NutritionLabelType } from "~/composables/recipes/use-recipe-nutrition";
export default defineNuxtComponent({ interface Props {
props: { edit?: boolean;
modelValue: { }
type: Object as () => Nutrition, const props = withDefaults(defineProps<Props>(), {
required: true, edit: 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;
});
const showViewer = computed(() => !props.edit && valueNotNull.value); const modelValue = defineModel<Nutrition>({ required: true });
function updateValue(key: number | string, event: Event) { const { labels } = useNutritionLabels();
context.emit("update:modelValue", { ...props.modelValue, [key]: event }); 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 showViewer = computed(() => !props.edit && valueNotNull.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;
}, {});
});
return { function updateValue(key: number | string, event: Event) {
labels, modelValue.value = { ...modelValue.value, [key]: event };
valueNotNull, }
showViewer,
updateValue, // Build a new list that only contains nutritional information that has a value
renderedList, 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> </script>

View file

@ -60,119 +60,93 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store"; import { useCategoryStore, useTagStore, useToolStore } from "~/composables/store";
import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated"; import { type RecipeOrganizer, Organizer } from "~/lib/api/types/non-generated";
const CREATED_ITEM_EVENT = "created-item"; const CREATED_ITEM_EVENT = "created-item";
export default defineNuxtComponent({ interface Props {
props: { color?: string | null;
modelValue: { tagDialog?: boolean;
type: Boolean, itemType?: RecipeOrganizer;
default: false, }
}, const props = withDefaults(defineProps<Props>(), {
color: { color: null,
type: String, tagDialog: true,
default: null, itemType: "category" as RecipeOrganizer,
},
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,
};
},
}); });
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> </script>
<style></style> <style></style>

View file

@ -122,9 +122,8 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { useContextPresets } from "~/composables/use-context-presents"; import { useContextPresets } from "~/composables/use-context-presents";
import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue"; import RecipeOrganizerDialog from "~/components/Domain/Recipe/RecipeOrganizerDialog.vue";
import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated"; import { Organizer, type RecipeOrganizer } from "~/lib/api/types/non-generated";
@ -138,156 +137,128 @@ interface GenericItem {
onHand: boolean; onHand: boolean;
} }
export default defineNuxtComponent({ const props = defineProps<{
components: { items: GenericItem[];
RecipeOrganizerDialog, icon: string;
}, itemType: RecipeOrganizer;
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 $auth = useMealieAuth(); const emit = defineEmits<{
const route = useRoute(); update: [item: GenericItem];
const groupSlug = computed(() => route.params.groupSlug as string || $auth.user?.value?.groupSlug || ""); delete: [id: string];
}>();
// ================================================================= const state = reactive({
// Context Menu // Search Options
options: {
const dialogs = ref({ ignoreLocation: true,
organizer: false, shouldSort: true,
update: false, threshold: 0.2,
delete: false, location: 0,
}); distance: 20,
findAllMatches: true,
const presets = useContextPresets(); maxPatternLength: 32,
minMatchCharLength: 1,
const translationKey = computed<string>(() => { keys: ["name"],
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 $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> </script>

View file

@ -37,7 +37,7 @@
<RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" /> <RecipePageIngredientEditor v-if="isEditForm" v-model="recipe" />
</div> </div>
<div> <div>
<RecipePageScale v-model:scale="scale" :recipe="recipe" /> <RecipePageScale v-model="scale" :recipe="recipe" />
</div> </div>
<!-- <!--
@ -96,7 +96,7 @@
<v-row style="height: 100%" no-gutters class="overflow-hidden"> <v-row style="height: 100%" no-gutters class="overflow-hidden">
<v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%"> <v-col cols="12" sm="5" class="overflow-y-auto pl-4 pr-3 py-2" style="height: 100%">
<div class="d-flex align-center"> <div class="d-flex align-center">
<RecipePageScale v-model:scale="scale" :recipe="recipe" /> <RecipePageScale v-model="scale" :recipe="recipe" />
</div> </div>
<RecipePageIngredientToolsView <RecipePageIngredientToolsView
v-if="!isEditForm" v-if="!isEditForm"
@ -124,7 +124,7 @@
</v-sheet> </v-sheet>
<v-sheet v-show="isCookMode && hasLinkedIngredients"> <v-sheet v-show="isCookMode && hasLinkedIngredients">
<div class="mt-2 px-2 px-md-4"> <div class="mt-2 px-2 px-md-4">
<RecipePageScale v-model:scale="scale" :recipe="recipe" /> <RecipePageScale v-model="scale" :recipe="recipe" />
</div> </div>
<RecipePageInstructions <RecipePageInstructions
v-model="recipe.recipeInstructions" v-model="recipe.recipeInstructions"

View file

@ -26,7 +26,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useRecipePermissions } from "~/composables/recipes"; import { useRecipePermissions } from "~/composables/recipes";
import RecipePageInfoCard from "~/components/Domain/Recipe/RecipePage/RecipePageParts/RecipePageInfoCard.vue"; 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 { HouseholdSummary } from "~/lib/api/types/household";
import type { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated"; 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({ interface Props {
components: { recipe: NoUndefinedField<Recipe>;
RecipePageInfoCard, recipeScale?: number;
RecipeActionMenu, landscape?: boolean;
}, }
props: { const props = withDefaults(defineProps<Props>(), {
recipe: { recipeScale: 1,
type: Object as () => NoUndefinedField<Recipe>, landscape: false,
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,
};
},
}); });
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> </script>

View file

@ -76,7 +76,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue"; import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
import RecipeLastMade from "~/components/Domain/Recipe/RecipeLastMade.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 { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({ interface Props {
components: { recipe: NoUndefinedField<Recipe>;
RecipeRating, recipeScale?: number;
RecipeLastMade, landscape: boolean;
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();
return { withDefaults(defineProps<Props>(), {
isOwnGroup, recipeScale: 1,
};
},
}); });
const { isOwnGroup } = useLoggedInState();
</script> </script>

View file

@ -12,60 +12,47 @@
/> />
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useStaticRoutes, useUserApi } from "~/composables/api"; import { useStaticRoutes, useUserApi } from "~/composables/api";
import type { HouseholdSummary } from "~/lib/api/types/household"; import type { HouseholdSummary } from "~/lib/api/types/household";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import type { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import type { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
export default defineNuxtComponent({ interface Props {
props: { recipe: NoUndefinedField<Recipe>;
recipe: { maxWidth?: string;
type: Object as () => NoUndefinedField<Recipe>, }
required: true, const props = withDefaults(defineProps<Props>(), {
}, maxWidth: undefined,
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,
};
},
}); });
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> </script>

View file

@ -36,7 +36,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useLoggedInState } from "~/composables/use-logged-in-state"; import { useLoggedInState } from "~/composables/use-logged-in-state";
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state"; import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
import { useToolStore } from "~/composables/store"; import { useToolStore } from "~/composables/store";
@ -48,71 +48,52 @@ interface RecipeToolWithOnHand extends RecipeTool {
onHand: boolean; onHand: boolean;
} }
export default defineNuxtComponent({ interface Props {
components: { recipe: NoUndefinedField<Recipe>;
RecipeIngredients, scale: number;
}, isCookMode?: boolean;
props: { }
recipe: { const props = withDefaults(defineProps<Props>(), {
type: Object as () => NoUndefinedField<Recipe>, isCookMode: false,
required: true, });
},
scale: {
type: Number,
required: true,
},
isCookMode: {
type: Boolean,
default: false,
},
},
setup(props) {
const { isOwnGroup } = useLoggedInState();
const toolStore = isOwnGroup.value ? useToolStore() : null; const { isOwnGroup } = useLoggedInState();
const { user } = usePageUser();
const { isEditMode } = usePageState(props.recipe.slug);
const recipeTools = computed(() => { const toolStore = isOwnGroup.value ? useToolStore() : null;
if (!(user.householdSlug && toolStore)) { const { user } = usePageUser();
return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand); const { isEditMode } = usePageState(props.recipe.slug);
}
else { const recipeTools = computed(() => {
return props.recipe.tools.map((tool) => { if (!(user.householdSlug && toolStore)) {
const onHand = tool.householdsWithTool?.includes(user.householdSlug) || false; return props.recipe.tools.map(tool => ({ ...tool, onHand: false }) as RecipeToolWithOnHand);
return { ...tool, onHand } 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) { function updateTool(index: number) {
if (user.id && user.householdSlug && toolStore) { if (user.id && user.householdSlug && toolStore) {
const tool = recipeTools.value[index]; const tool = recipeTools.value[index];
if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) { if (tool.onHand && !tool.householdsWithTool?.includes(user.householdSlug)) {
if (!tool.householdsWithTool) { if (!tool.householdsWithTool) {
tool.householdsWithTool = [user.householdSlug]; 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);
} }
else { 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.actions.updateOne(tool);
toolStore, }
recipeTools, else {
isEditMode, console.log("no user, skipping server update");
updateTool, }
}; }
},
});
</script> </script>

View file

@ -2,55 +2,26 @@
<div class="d-flex justify-space-between align-center pt-2 pb-3"> <div class="d-flex justify-space-between align-center pt-2 pb-3">
<RecipeScaleEditButton <RecipeScaleEditButton
v-if="!isEditMode" v-if="!isEditMode"
v-model.number="scaleValue" v-model.number="scale"
:recipe-servings="recipeServings" :recipe-servings="recipeServings"
:edit-scale="!recipe.settings.disableAmount && !isEditMode" :edit-scale="!recipe.settings.disableAmount && !isEditMode"
/> />
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue"; import RecipeScaleEditButton from "~/components/Domain/Recipe/RecipeScaleEditButton.vue";
import type { NoUndefinedField } from "~/lib/api/types/non-generated"; import type { NoUndefinedField } from "~/lib/api/types/non-generated";
import type { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
import { usePageState } from "~/composables/recipe-page/shared-state"; import { usePageState } from "~/composables/recipe-page/shared-state";
export default defineNuxtComponent({ const props = defineProps<{ recipe: NoUndefinedField<Recipe> }>();
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 recipeServings = computed<number>(() => { const scale = defineModel<number>({ default: 1 });
return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
});
const scaleValue = computed<number>({ const { isEditMode } = usePageState(props.recipe.slug);
get() {
return props.scale;
},
set(val) {
emit("update:scale", val);
},
});
return { const recipeServings = computed<number>(() => {
recipeServings, return props.recipe.recipeServings || props.recipe.recipeYieldQuantity || 1;
scaleValue,
isEditMode,
};
},
}); });
</script> </script>

View file

@ -8,24 +8,17 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue"; import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
import type { Recipe } from "~/lib/api/types/recipe"; import type { Recipe } from "~/lib/api/types/recipe";
export default defineNuxtComponent({ interface Props {
components: { recipe: Recipe;
RecipePrintView, scale?: number;
}, }
props: {
recipe: { withDefaults(defineProps<Props>(), {
type: Object as () => Recipe, scale: 1,
required: true,
},
scale: {
type: Number,
default: 1,
},
},
}); });
</script> </script>

View file

@ -166,7 +166,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue"; import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
import { useStaticRoutes } from "~/composables/api"; import { useStaticRoutes } from "~/composables/api";
@ -188,167 +188,141 @@ type InstructionSection = {
instructions: RecipeStep[]; instructions: RecipeStep[];
}; };
export default defineNuxtComponent({ interface Props {
components: { recipe: NoUndefinedField<Recipe>;
RecipeTimeCard, scale?: number;
}, dense?: boolean;
props: { }
recipe: { const props = withDefaults(defineProps<Props>(), {
type: Object as () => NoUndefinedField<Recipe>, scale: 1,
required: true, dense: false,
},
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,
};
},
}); });
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> </script>
<style scoped> <style scoped>

View file

@ -10,7 +10,7 @@
nudge-top="6" nudge-top="6"
:close-on-content-click="false" :close-on-content-click="false"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-tooltip <v-tooltip
v-if="canEditScale" v-if="canEditScale"
size="small" size="small"
@ -23,7 +23,7 @@
dark dark
color="secondary-darken-1" color="secondary-darken-1"
size="small" size="small"
v-bind="{ ...props, ...tooltipProps }" v-bind="{ ...activatorProps, ...tooltipProps }"
:style="{ cursor: canEditScale ? '' : 'default' }" :style="{ cursor: canEditScale ? '' : 'default' }"
> >
<v-icon <v-icon
@ -45,7 +45,7 @@
dark dark
color="secondary-darken-1" color="secondary-darken-1"
size="small" size="small"
v-bind="props" v-bind="activatorProps"
:style="{ cursor: canEditScale ? '' : 'default' }" :style="{ cursor: canEditScale ? '' : 'default' }"
> >
<v-icon <v-icon
@ -77,9 +77,9 @@
location="end" location="end"
color="secondary-darken-1" color="secondary-darken-1"
> >
<template #activator="{ props }"> <template #activator="{ props: resetTooltipProps }">
<v-btn <v-btn
v-bind="props" v-bind="resetTooltipProps"
icon icon
flat flat
class="mx-1" class="mx-1"
@ -122,76 +122,50 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount"; import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineNuxtComponent({ interface Props {
props: { recipeServings?: number;
modelValue: { editScale?: boolean;
type: Number, }
required: true, const props = withDefaults(defineProps<Props>(), {
}, recipeServings: 0,
recipeServings: { editScale: false,
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);
const scale = computed({ const scale = defineModel<number>({ required: true });
get: () => props.modelValue,
set: (value) => {
const newScaleNumber = parseFloat(`${value}`);
emit("update:modelValue", isNaN(newScaleNumber) ? 0 : newScaleNumber);
},
});
function recalculateScale(newYield: number) { const i18n = useI18n();
if (isNaN(newYield) || newYield <= 0) { const menu = ref<boolean>(false);
return; const canEditScale = computed(() => props.editScale && props.recipeServings > 0);
}
if (props.recipeServings <= 0) { function recalculateScale(newYield: number) {
scale.value = 1; if (isNaN(newYield) || newYield <= 0) {
} return;
else { }
scale.value = newYield / props.recipeServings;
}
}
const recipeYieldAmount = computed(() => { if (props.recipeServings <= 0) {
return useScaledAmount(props.recipeServings, scale.value); scale.value = 1;
}); }
const yieldQuantity = computed(() => recipeYieldAmount.value.scaledAmount); else {
const yieldDisplay = computed(() => { scale.value = newYield / props.recipeServings;
return yieldQuantity.value }
? i18n.t( }
"recipe.serves-amount", { amount: recipeYieldAmount.value.scaledAmountDisplay },
) as string
: "";
});
const disableDecrement = computed(() => { const recipeYieldAmount = computed(() => {
return yieldQuantity.value <= 1; 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 { const disableDecrement = computed(() => {
menu, return yieldQuantity.value <= 1;
canEditScale,
scale,
recalculateScale,
yieldDisplay,
yieldQuantity,
disableDecrement,
};
},
}); });
</script> </script>

View file

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

View file

@ -14,9 +14,7 @@
<div v-for="(organizer, idx) in missingOrganizers" :key="idx"> <div v-for="(organizer, idx) in missingOrganizers" :key="idx">
<v-col v-if="organizer.show" cols="12"> <v-col v-if="organizer.show" cols="12">
<div class="d-flex flex-row flex-wrap align-center pt-2"> <div class="d-flex flex-row flex-wrap align-center pt-2">
<v-icon class="ma-0 pa-0"> <v-icon class="ma-0 pa-0" />
{{ organizer.icon }}
</v-icon>
<v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content"> <v-card-text class="mr-0 my-0 pl-1 py-0" style="width: min-content">
{{ $t("recipe-finder.missing") }}: {{ $t("recipe-finder.missing") }}:
</v-card-text> </v-card-text>
@ -41,7 +39,7 @@
</v-container> </v-container>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe"; import type { IngredientFood, RecipeSummary, RecipeTool } from "~/lib/api/types/recipe";
@ -51,71 +49,72 @@ interface Organizer {
selected: boolean; selected: boolean;
} }
export default defineNuxtComponent({ interface Props {
components: { RecipeCardMobile }, recipe: RecipeSummary;
props: { missingFoods?: IngredientFood[] | null;
recipe: { missingTools?: RecipeTool[] | null;
type: Object as () => RecipeSummary, disableCheckbox?: boolean;
required: true, }
}, const props = withDefaults(defineProps<Props>(), {
missingFoods: { missingFoods: null,
type: Array as () => IngredientFood[] | null, missingTools: null,
default: null, disableCheckbox: false,
},
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,
};
},
}); });
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> </script>

View file

@ -1,4 +1,4 @@
<template v-if="showCards"> <template v-if="_showCards">
<div class="text-center"> <div class="text-center">
<!-- Total Time --> <!-- Total Time -->
<div <div
@ -78,65 +78,46 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
export default defineNuxtComponent({ interface Props {
props: { prepTime?: string | null;
prepTime: { totalTime?: string | null;
type: String, performTime?: string | null;
default: null, color?: string;
}, small?: boolean;
totalTime: { }
type: String, const props = withDefaults(defineProps<Props>(), {
default: null, prepTime: null,
}, totalTime: null,
performTime: { performTime: null,
type: String, color: "accent custom-transparent",
default: null, small: false,
}, });
color: {
type: String,
default: "accent custom-transparent",
},
small: {
type: Boolean,
default: false,
},
},
setup(props) {
const i18n = useI18n();
function isEmpty(str: string | null) { const i18n = useI18n();
return !str || str.length === 0;
}
const showCards = computed(() => { function isEmpty(str: string | null) {
return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x)); return !str || str.length === 0;
}); }
const validateTotalTime = computed(() => { const _showCards = computed(() => {
return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null; return [props.prepTime, props.totalTime, props.performTime].some(x => !isEmpty(x));
}); });
const validatePrepTime = computed(() => { const validateTotalTime = computed(() => {
return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null; return !isEmpty(props.totalTime) ? { name: i18n.t("recipe.total-time"), value: props.totalTime } : null;
}); });
const validatePerformTime = computed(() => { const validatePrepTime = computed(() => {
return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null; return !isEmpty(props.prepTime) ? { name: i18n.t("recipe.prep-time"), value: props.prepTime } : null;
}); });
const fontSize = computed(() => { const validatePerformTime = computed(() => {
return props.small ? { fontSize: "smaller" } : { fontSize: "larger" }; return !isEmpty(props.performTime) ? { name: i18n.t("recipe.perform-time"), value: props.performTime } : null;
}); });
return { const fontSize = computed(() => {
showCards, return props.small ? { fontSize: "smaller" } : { fontSize: "larger" };
validateTotalTime,
validatePrepTime,
validatePerformTime,
fontSize,
};
},
}); });
</script> </script>

View file

@ -11,7 +11,7 @@
nudge-bottom="3" nudge-bottom="3"
:close-on-content-click="false" :close-on-content-click="false"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-badge <v-badge
:content="filterBadgeCount" :content="filterBadgeCount"
:model-value="filterBadgeCount > 0" :model-value="filterBadgeCount > 0"
@ -21,7 +21,7 @@
class="rounded-circle" class="rounded-circle"
size="small" size="small"
color="info" color="info"
v-bind="props" v-bind="activatorProps"
icon icon
> >
<v-icon> {{ $globals.icons.filter }} </v-icon> <v-icon> {{ $globals.icons.filter }} </v-icon>
@ -105,7 +105,7 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { useThrottleFn, whenever } from "@vueuse/core"; import { useThrottleFn, whenever } from "@vueuse/core";
import RecipeTimelineItem from "./RecipeTimelineItem.vue"; import RecipeTimelineItem from "./RecipeTimelineItem.vue";
import { useTimelinePreferences } from "~/composables/use-users/preferences"; import { useTimelinePreferences } from "~/composables/use-users/preferences";
@ -115,252 +115,208 @@ import { alert } from "~/composables/use-toast";
import { useUserApi } from "~/composables/api"; import { useUserApi } from "~/composables/api";
import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe"; import type { Recipe, RecipeTimelineEventOut, RecipeTimelineEventUpdate, TimelineEventType } from "~/lib/api/types/recipe";
export default defineNuxtComponent({ interface Props {
components: { RecipeTimelineItem }, modelValue?: boolean;
queryFilter: string;
maxHeight?: number | string;
showRecipeCards?: boolean;
}
props: { const props = withDefaults(defineProps<Props>(), {
modelValue: { modelValue: false,
type: Boolean, maxHeight: undefined,
default: false, showRecipeCards: false,
}, });
queryFilter: {
type: String, const api = useUserApi();
required: true, const i18n = useI18n();
}, const preferences = useTimelinePreferences();
maxHeight: { const { eventTypeOptions } = useTimelineEventTypes();
type: [Number, String], const loading = ref(true);
default: undefined, const ready = ref(false);
},
showRecipeCards: { const page = ref(1);
type: Boolean, const perPage = 32;
default: false, 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) { // Preferences
const api = useUserApi(); function reverseSort() {
const i18n = useI18n(); if (loading.value) {
const preferences = useTimelinePreferences(); return;
const { eventTypeOptions } = useTimelineEventTypes(); }
const loading = ref(true);
const ready = ref(false);
const page = ref(1); preferences.value.orderDirection = preferences.value.orderDirection === "asc" ? "desc" : "asc";
const perPage = 32; initializeTimelineEvents();
const hasMore = ref(true); }
const timelineEvents = ref([] as RecipeTimelineEventOut[]); function toggleEventTypeOption(option: TimelineEventType) {
const recipes = new Map<string, Recipe>(); if (loading.value) {
const filterBadgeCount = computed(() => eventTypeOptions.value.length - preferences.value.types.length); return;
const eventTypeFilterState = computed(() => { }
return eventTypeOptions.value.map((option) => {
return {
...option,
checked: preferences.value.types.includes(option.value),
};
});
});
interface ScrollEvent extends Event { const index = preferences.value.types.indexOf(option);
target: HTMLInputElement; 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; recipeIds.push(event.recipeId);
const onScroll = (event: ScrollEvent) => { });
if (!event.target) {
return; 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; const bottomOfWindow = document.documentElement.scrollTop + window.innerHeight >= document.documentElement.offsetHeight - (window.innerHeight * screenBuffer);
if (bottomOfWindow) {
// trigger when the user is getting close to the bottom
const bottomOfElement = scrollTop + offsetHeight >= scrollHeight - (offsetHeight * screenBuffer);
if (bottomOfElement) {
infiniteScroll(); 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> </script>

View file

@ -4,7 +4,7 @@
nudge-right="50" nudge-right="50"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
> >
<template #activator="{ props }"> <template #activator="{ props: activatorProps }">
<v-btn <v-btn
icon icon
:variant="buttonStyle ? 'flat' : undefined" :variant="buttonStyle ? 'flat' : undefined"
@ -12,7 +12,7 @@
size="small" size="small"
:color="buttonStyle ? 'info' : 'secondary'" :color="buttonStyle ? 'info' : 'secondary'"
:fab="buttonStyle" :fab="buttonStyle"
v-bind="{ ...props, ...$attrs }" v-bind="{ ...activatorProps, ...$attrs }"
@click.prevent="toggleTimeline" @click.prevent="toggleTimeline"
> >
<v-icon <v-icon
@ -39,48 +39,37 @@
</v-tooltip> </v-tooltip>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeTimeline from "./RecipeTimeline.vue"; import RecipeTimeline from "./RecipeTimeline.vue";
export default defineNuxtComponent({ interface Props {
components: { RecipeTimeline }, buttonStyle?: boolean;
slug?: string;
recipeName?: string;
}
const props = withDefaults(defineProps<Props>(), {
buttonStyle: false,
slug: "",
recipeName: "",
});
props: { const i18n = useI18n();
buttonStyle: { const { smAndDown } = useDisplay();
type: Boolean, const showTimeline = ref(false);
default: false,
},
slug: {
type: String,
default: "",
},
recipeName: {
type: String,
default: "",
},
},
setup(props) { function toggleTimeline() {
const i18n = useI18n(); showTimeline.value = !showTimeline.value;
const { smAndDown } = useDisplay(); }
const showTimeline = ref(false);
function toggleTimeline() {
showTimeline.value = !showTimeline.value;
}
const timelineAttrs = computed(() => { const timelineAttrs = computed(() => {
let title = i18n.t("recipe.timeline"); let title = i18n.t("recipe.timeline");
if (smAndDown.value) { if (smAndDown.value) {
title += ` ${props.recipeName}`; title += ` ${props.recipeName}`;
} }
return { return {
title, title,
queryFilter: `recipe.slug="${props.slug}"`, queryFilter: `recipe.slug="${props.slug}"`,
}; };
});
return { showTimeline, timelineAttrs, toggleTimeline };
},
}); });
</script> </script>

View file

@ -91,7 +91,7 @@
</v-timeline-item> </v-timeline-item>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import RecipeCardMobile from "./RecipeCardMobile.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue";
import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue"; import RecipeTimelineContextMenu from "./RecipeTimelineContextMenu.vue";
import { useStaticRoutes } from "~/composables/api"; 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 UserAvatar from "~/components/Domain/User/UserAvatar.vue";
import SafeMarkdown from "~/components/global/SafeMarkdown.vue"; import SafeMarkdown from "~/components/global/SafeMarkdown.vue";
export default defineNuxtComponent({ interface Props {
components: { RecipeCardMobile, RecipeTimelineContextMenu, UserAvatar, SafeMarkdown }, event: RecipeTimelineEventOut;
recipe?: Recipe;
showRecipeCards?: boolean;
}
props: { const props = withDefaults(defineProps<Props>(), {
event: { recipe: undefined,
type: Object as () => RecipeTimelineEventOut, showRecipeCards: false,
required: true, });
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
showRecipeCards: {
type: Boolean,
default: false,
},
},
emits: ["selected", "update", "delete"],
setup(props) { defineEmits<{
const { $vuetify, $globals } = useNuxtApp(); selected: [];
const { recipeTimelineEventImage } = useStaticRoutes(); update: [];
const { eventTypeOptions } = useTimelineEventTypes(); delete: [];
const timelineEvents = ref([] as RecipeTimelineEventOut[]); }>();
const { user: currentUser } = useMealieAuth(); const { $vuetify, $globals } = useNuxtApp();
const { recipeTimelineEventImage } = useStaticRoutes();
const { eventTypeOptions } = useTimelineEventTypes();
const route = useRoute(); const { user: currentUser } = useMealieAuth();
const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
const useMobileFormat = computed(() => { const route = useRoute();
return $vuetify.display.smAndDown.value; const groupSlug = computed(() => (route.params.groupSlug as string) || currentUser?.value?.groupSlug || "");
});
const attrs = computed(() => { const useMobileFormat = computed(() => {
if (useMobileFormat.value) { return $vuetify.display.smAndDown.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 attrs = computed(() => {
if (useMobileFormat.value) {
return { return {
attrs, class: "px-0",
groupSlug, small: false,
icon, avatar: {
eventImageUrl, size: "30px",
hideImage, class: "pr-0",
timelineEvents, },
useMobileFormat, image: {
currentUser, 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> </script>

View file

@ -24,56 +24,43 @@
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { useScaledAmount } from "~/composables/recipes/use-scaled-amount"; import { useScaledAmount } from "~/composables/recipes/use-scaled-amount";
export default defineNuxtComponent({ interface Props {
props: { yieldQuantity?: number;
yieldQuantity: { yieldText?: string;
type: Number, scale?: number;
default: 0, color?: string;
}, }
yieldText: { const props = withDefaults(defineProps<Props>(), {
type: String, yieldQuantity: 0,
default: "", yieldText: "",
}, scale: 1,
scale: { color: "accent custom-transparent",
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"],
});
}
const yieldDisplay = computed<string>(() => { function sanitizeHTML(rawHtml: string) {
const components: string[] = []; return DOMPurify.sanitize(rawHtml, {
USE_PROFILES: { html: true },
ALLOWED_TAGS: ["strong", "sup"],
});
}
const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale); const yieldDisplay = computed<string>(() => {
if (scaledAmountDisplay) { const components: string[] = [];
components.push(scaledAmountDisplay);
}
const text = props.yieldText; const { scaledAmountDisplay } = useScaledAmount(props.yieldQuantity, props.scale);
if (text) { if (scaledAmountDisplay) {
components.push(text); components.push(scaledAmountDisplay);
} }
return sanitizeHTML(components.join(" ")); const text = props.yieldText;
}); if (text) {
components.push(text);
}
return { return sanitizeHTML(components.join(" "));
yieldDisplay,
};
},
}); });
</script> </script>

View file

@ -40,7 +40,6 @@
v-if="requireAll != undefined" v-if="requireAll != undefined"
v-model="requireAllValue" v-model="requireAllValue"
density="compact" density="compact"
size="small"
hide-details hide-details
class="my-auto" class="my-auto"
color="primary" color="primary"

View file

@ -93,7 +93,7 @@
<!-- Alias Sub-Dialog --> <!-- Alias Sub-Dialog -->
<RecipeDataAliasManagerDialog <RecipeDataAliasManagerDialog
v-if="editTarget" v-if="editTarget"
:value="aliasManagerDialog" v-model="aliasManagerDialog"
:data="editTarget" :data="editTarget"
can-submit can-submit
@submit="updateUnitAlias" @submit="updateUnitAlias"

View file

@ -371,6 +371,8 @@
<v-btn <v-btn
v-if="recipe" v-if="recipe"
icon icon
flat
class="bg-transparent"
:disabled="isOffline" :disabled="isOffline"
@click.prevent="removeRecipeReferenceToList(recipe.id!)" @click.prevent="removeRecipeReferenceToList(recipe.id!)"
> >
@ -386,6 +388,8 @@
<v-btn <v-btn
icon icon
:disabled="isOffline" :disabled="isOffline"
flat
class="bg-transparent"
@click.prevent="addRecipeReferenceToList(recipe.id!)" @click.prevent="addRecipeReferenceToList(recipe.id!)"
> >
<v-icon color="grey-lighten-1"> <v-icon color="grey-lighten-1">