Recipe Context Menu

This commit is contained in:
Kuchenpirat 2025-07-30 10:10:03 +00:00
commit 1cb1a0552b

View file

@ -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,16 +186,22 @@ 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;
recipe?: Recipe;
recipeId: string;
recipeScale?: number;
}
const props = withDefaults(defineProps<Props>(), {
useItems: () => ({
delete: true, delete: true,
edit: true, edit: true,
download: true, download: true,
@ -207,98 +213,61 @@ export default defineNuxtComponent({
share: true, share: true,
recipeActions: true, recipeActions: true,
}), }),
}, appendItems: () => [],
// Append items are added at the end of the useItems list leadingItems: () => [],
appendItems: { menuTop: true,
type: Array as () => ContextMenuItem[], fab: false,
default: () => [], color: "primary",
}, menuIcon: null,
// Append items are added at the beginning of the useItems list recipe: undefined,
leadingItems: { recipeScale: 1,
type: Array as () => ContextMenuItem[], });
default: () => [],
},
menuTop: {
type: Boolean,
default: true,
},
fab: {
type: Boolean,
default: false,
},
color: {
type: String,
default: "primary",
},
slug: {
type: String,
required: true,
},
menuIcon: {
type: String,
default: null,
},
name: {
required: true,
type: String,
},
recipe: {
type: Object as () => Recipe,
default: undefined,
},
recipeId: {
required: true,
type: String,
},
recipeScale: {
type: Number,
default: 1,
},
},
emits: ["delete"],
setup(props, context) {
const api = useUserApi();
const state = reactive({ const emit = defineEmits<{
printPreferencesDialog: false, [key: string]: any;
shareDialog: false, delete: [slug: string];
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(() => { 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 // Format the date to YYYY-MM-DD in the same timezone as newMealdate
const year = state.newMealdate.getFullYear(); const year = newMealdate.value.getFullYear();
const month = String(state.newMealdate.getMonth() + 1).padStart(2, "0"); const month = String(newMealdate.value.getMonth() + 1).padStart(2, "0");
const day = String(state.newMealdate.getDate()).padStart(2, "0"); const day = String(newMealdate.value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
}); });
const i18n = useI18n(); const i18n = useI18n();
const $auth = useMealieAuth(); const $auth = useMealieAuth();
const { $globals } = useNuxtApp(); const { $globals } = useNuxtApp();
const { household } = useHouseholdSelf(); const { household } = useHouseholdSelf();
const { isOwnGroup } = useLoggedInState(); const { isOwnGroup } = useLoggedInState();
const route = useRoute(); const route = useRoute();
const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || ""); const groupSlug = computed(() => route.params.groupSlug || $auth.user.value?.groupSlug || "");
const firstDayOfWeek = computed(() => { const firstDayOfWeek = computed(() => {
return household.value?.preferences?.firstDayOfWeek || 0; return household.value?.preferences?.firstDayOfWeek || 0;
}); });
// =========================================================================== // ===========================================================================
// Context Menu Setup // Context Menu Setup
const defaultItems: { [key: string]: ContextMenuItem } = { const defaultItems: { [key: string]: ContextMenuItem } = {
edit: { edit: {
title: i18n.t("general.edit"), title: i18n.t("general.edit"),
icon: $globals.icons.edit, icon: $globals.icons.edit,
@ -362,35 +331,35 @@ export default defineNuxtComponent({
event: "share", event: "share",
isPublic: false, isPublic: false,
}, },
}; };
// Add leading and Appending Items // Add leading and Appending Items
state.menuItems = [...state.menuItems, ...props.leadingItems, ...props.appendItems]; menuItems.value = [...menuItems.value, ...props.leadingItems, ...props.appendItems];
const icon = props.menuIcon || $globals.icons.dotsVertical; const icon = props.menuIcon || $globals.icons.dotsVertical;
// =========================================================================== // ===========================================================================
// Context Menu Event Handler // Context Menu Event Handler
const shoppingLists = ref<ShoppingListSummary[]>(); const shoppingLists = ref<ShoppingListSummary[]>();
const recipeRef = ref<Recipe | undefined>(props.recipe); const recipeRef = ref<Recipe | undefined>(props.recipe);
const recipeRefWithScale = computed(() => const recipeRefWithScale = computed(() =>
recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined, recipeRef.value ? { scale: props.recipeScale, ...recipeRef.value } : undefined,
); );
const isAdminAndNotOwner = computed(() => { const isAdminAndNotOwner = computed(() => {
return ( return (
$auth.user.value?.admin $auth.user.value?.admin
&& $auth.user.value?.id !== recipeRef.value?.userId && $auth.user.value?.id !== recipeRef.value?.userId
); );
}); });
const canDelete = computed(() => { const canDelete = computed(() => {
const user = $auth.user.value; const user = $auth.user.value;
const recipe = recipeRef.value; const recipe = recipeRef.value;
return user && recipe && (user.admin || user.id === recipe.userId); return user && recipe && (user.admin || user.id === recipe.userId);
}); });
// Get Default Menu Items Specified in Props // Get Default Menu Items Specified in Props
for (const [key, value] of Object.entries(props.useItems)) { for (const [key, value] of Object.entries(props.useItems)) {
if (!value) continue; if (!value) continue;
// Skip delete if not allowed // Skip delete if not allowed
@ -398,28 +367,28 @@ export default defineNuxtComponent({
const item = defaultItems[key]; const item = defaultItems[key];
if (item && (item.isPublic || isOwnGroup.value)) { if (item && (item.isPublic || isOwnGroup.value)) {
state.menuItems.push(item); menuItems.value.push(item);
}
} }
}
async function getShoppingLists() { async function getShoppingLists() {
const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" }); const { data } = await api.shopping.lists.getAll(1, -1, { orderBy: "name", orderDirection: "asc" });
if (data) { if (data) {
shoppingLists.value = data.items ?? []; shoppingLists.value = data.items ?? [];
} }
} }
async function refreshRecipe() { async function refreshRecipe() {
const { data } = await api.recipes.getOne(props.slug); const { data } = await api.recipes.getOne(props.slug);
if (data) { if (data) {
recipeRef.value = data; recipeRef.value = data;
} }
} }
const router = useRouter(); const router = useRouter();
const groupRecipeActionsStore = useGroupRecipeActions(); const groupRecipeActionsStore = useGroupRecipeActions();
async function executeRecipeAction(action: GroupRecipeActionOut) { async function executeRecipeAction(action: GroupRecipeActionOut) {
if (!props.recipe) return; if (!props.recipe) return;
const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale); const response = await groupRecipeActionsStore.execute(action, props.recipe, props.recipeScale);
@ -431,30 +400,30 @@ export default defineNuxtComponent({
alert.error(i18n.t("events.something-went-wrong")); alert.error(i18n.t("events.something-went-wrong"));
} }
} }
} }
async function deleteRecipe() { async function deleteRecipe() {
const { data } = await api.recipes.deleteOne(props.slug); const { data } = await api.recipes.deleteOne(props.slug);
if (data?.slug) { if (data?.slug) {
router.push(`/g/${groupSlug.value}`); router.push(`/g/${groupSlug.value}`);
} }
context.emit("delete", props.slug); emit("delete", props.slug);
} }
const download = useDownloader(); const download = useDownloader();
async function handleDownloadEvent() { async function handleDownloadEvent() {
const { data } = await api.recipes.getZipToken(props.slug); const { data } = await api.recipes.getZipToken(props.slug);
if (data) { if (data) {
download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`); download(api.recipes.getZipRedirectUrl(props.slug, data.token), `${props.slug}.zip`);
} }
} }
async function addRecipeToPlan() { async function addRecipeToPlan() {
const { response } = await api.mealplans.createOne({ const { response } = await api.mealplans.createOne({
date: newMealdateString.value, date: newMealdateString.value,
entryType: state.newMealType, entryType: newMealType.value,
title: "", title: "",
text: "", text: "",
recipeId: props.recipeId, recipeId: props.recipeId,
@ -466,34 +435,34 @@ export default defineNuxtComponent({
else { else {
alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string); alert.error(i18n.t("recipe.failed-to-add-recipe-to-mealplan") as string);
} }
} }
async function duplicateRecipe() { async function duplicateRecipe() {
const { data } = await api.recipes.duplicateOne(props.slug, state.recipeName); const { data } = await api.recipes.duplicateOne(props.slug, recipeName.value);
if (data && data.slug) { if (data && data.slug) {
router.push(`/g/${groupSlug.value}/r/${data.slug}`); router.push(`/g/${groupSlug.value}/r/${data.slug}`);
} }
} }
// Note: Print is handled as an event in the parent component // Note: Print is handled as an event in the parent component
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type // eslint-disable-next-line @typescript-eslint/no-invalid-void-type
const eventHandlers: { [key: string]: () => void | Promise<any> } = { const eventHandlers: { [key: string]: () => void | Promise<any> } = {
delete: () => { delete: () => {
state.recipeDeleteDialog = true; recipeDeleteDialog.value = true;
}, },
edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"), edit: () => router.push(`/g/${groupSlug.value}/r/${props.slug}` + "?edit=true"),
download: handleDownloadEvent, download: handleDownloadEvent,
duplicate: () => { duplicate: () => {
state.recipeDuplicateDialog = true; recipeDuplicateDialog.value = true;
}, },
mealplanner: () => { mealplanner: () => {
state.mealplannerDialog = true; mealplannerDialog.value = true;
}, },
printPreferences: async () => { printPreferences: async () => {
if (!recipeRef.value) { if (!recipeRef.value) {
await refreshRecipe(); await refreshRecipe();
} }
state.printPreferencesDialog = true; printPreferencesDialog.value = true;
}, },
shoppingList: () => { shoppingList: () => {
const promises: Promise<void>[] = [getShoppingLists()]; const promises: Promise<void>[] = [getShoppingLists()];
@ -502,47 +471,27 @@ export default defineNuxtComponent({
} }
Promise.allSettled(promises).then(() => { Promise.allSettled(promises).then(() => {
state.shoppingListDialog = true; shoppingListDialog.value = true;
}); });
}, },
share: () => { share: () => {
state.shareDialog = true; shareDialog.value = true;
}, },
}; };
function contextMenuEventHandler(eventKey: string) { function contextMenuEventHandler(eventKey: string) {
const handler = eventHandlers[eventKey]; const handler = eventHandlers[eventKey];
if (handler && typeof handler === "function") { if (handler && typeof handler === "function") {
handler(); handler();
state.loading = false; loading.value = false;
return; return;
} }
context.emit(eventKey); emit(eventKey);
state.loading = false; loading.value = false;
} }
const planTypeOptions = usePlanTypeOptions(); const planTypeOptions = usePlanTypeOptions();
const recipeActions = groupRecipeActionsStore.recipeActions;
return {
...toRefs(state),
newMealdateString,
recipeRef,
recipeRefWithScale,
executeRecipeAction,
recipeActions: groupRecipeActionsStore.recipeActions,
shoppingLists,
duplicateRecipe,
contextMenuEventHandler,
deleteRecipe,
addRecipeToPlan,
icon,
planTypeOptions,
firstDayOfWeek,
isAdminAndNotOwner,
canDelete,
};
},
});
</script> </script>