mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-08-02 20:15:24 +02:00
feat: public recipe access (#1610)
* initial public explorer API endpoint * public API endpoint * cleanup recipe page * wip: init explorer page * use public URLs for shared recipes * refactor private share tokens to use shared page
This commit is contained in:
parent
9ea5e6584f
commit
18b2c92a76
24 changed files with 361 additions and 437 deletions
|
@ -21,7 +21,7 @@
|
|||
|
||||
<v-spacer></v-spacer>
|
||||
<div v-if="!open" class="custom-btn-group ma-1">
|
||||
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="slug" show-always />
|
||||
<RecipeFavoriteBadge v-if="loggedIn" class="mx-1" color="info" button-style :slug="recipe.slug" show-always />
|
||||
<v-tooltip v-if="!locked" bottom color="info">
|
||||
<template #activator="{ on, attrs }">
|
||||
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||
|
@ -39,29 +39,29 @@
|
|||
<span> {{ $t("recipe.locked-by-owner") }} </span>
|
||||
</v-tooltip>
|
||||
|
||||
<ClientOnly>
|
||||
<RecipeContextMenu
|
||||
show-print
|
||||
:menu-top="false"
|
||||
:name="name"
|
||||
:slug="slug"
|
||||
:menu-icon="$globals.icons.mdiDotsHorizontal"
|
||||
fab
|
||||
color="info"
|
||||
:card-menu="false"
|
||||
:recipe-id="recipeId"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
share: true,
|
||||
}"
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<RecipeContextMenu
|
||||
show-print
|
||||
:menu-top="false"
|
||||
:name="recipe.name"
|
||||
:group-id="recipe.groupId"
|
||||
:slug="recipe.slug"
|
||||
:menu-icon="$globals.icons.dotsVertical"
|
||||
fab
|
||||
color="info"
|
||||
:card-menu="false"
|
||||
:recipe-id="recipe.id"
|
||||
:use-items="{
|
||||
delete: false,
|
||||
edit: false,
|
||||
download: true,
|
||||
mealplanner: true,
|
||||
shoppingList: true,
|
||||
print: true,
|
||||
share: true,
|
||||
publicUrl: recipe.settings ? recipe.settings.public : false,
|
||||
}"
|
||||
@print="$emit('print')"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="open" class="custom-btn-group mb-">
|
||||
<v-btn
|
||||
|
@ -84,6 +84,7 @@
|
|||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||
import { Recipe } from "~/types/api-types/recipe";
|
||||
|
||||
const SAVE_EVENT = "save";
|
||||
const DELETE_EVENT = "delete";
|
||||
|
@ -93,6 +94,10 @@ const JSON_EVENT = "json";
|
|||
export default defineComponent({
|
||||
components: { RecipeContextMenu, RecipeFavoriteBadge },
|
||||
props: {
|
||||
recipe: {
|
||||
required: true,
|
||||
type: Object as () => Recipe,
|
||||
},
|
||||
slug: {
|
||||
required: true,
|
||||
type: String,
|
||||
|
|
|
@ -50,6 +50,7 @@
|
|||
shoppingList: true,
|
||||
print: false,
|
||||
share: true,
|
||||
publicUrl: false,
|
||||
}"
|
||||
@delete="$emit('delete', slug)"
|
||||
/>
|
||||
|
|
|
@ -49,6 +49,7 @@
|
|||
shoppingList: true,
|
||||
print: false,
|
||||
share: true,
|
||||
publicUrl: false,
|
||||
}"
|
||||
@deleted="$emit('delete', slug)"
|
||||
/>
|
||||
|
|
|
@ -104,6 +104,7 @@ import { planTypeOptions } from "~/composables/use-group-mealplan";
|
|||
import { ShoppingListSummary } from "~/types/api-types/group";
|
||||
import { PlanEntryType } from "~/types/api-types/meal-plan";
|
||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
||||
import { useCopy } from "~/composables/use-copy";
|
||||
|
||||
export interface ContextMenuIncludes {
|
||||
delete: boolean;
|
||||
|
@ -113,6 +114,7 @@ export interface ContextMenuIncludes {
|
|||
shoppingList: boolean;
|
||||
print: boolean;
|
||||
share: boolean;
|
||||
publicUrl: boolean;
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
|
@ -137,6 +139,7 @@ export default defineComponent({
|
|||
shoppingList: true,
|
||||
print: true,
|
||||
share: true,
|
||||
publicUrl: false,
|
||||
}),
|
||||
},
|
||||
// Append items are added at the end of the useItems list
|
||||
|
@ -177,6 +180,15 @@ export default defineComponent({
|
|||
required: true,
|
||||
type: String,
|
||||
},
|
||||
/**
|
||||
* Optional group ID prop that is only _required_ when the
|
||||
* public URL is requested. If the public URL button is pressed
|
||||
* and the groupId is not set, an error will be thrown.
|
||||
*/
|
||||
groupId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
},
|
||||
setup(props, context) {
|
||||
const api = useUserApi();
|
||||
|
@ -200,47 +212,53 @@ export default defineComponent({
|
|||
|
||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||
edit: {
|
||||
title: i18n.t("general.edit") as string,
|
||||
title: i18n.tc("general.edit"),
|
||||
icon: $globals.icons.edit,
|
||||
color: undefined,
|
||||
event: "edit",
|
||||
},
|
||||
delete: {
|
||||
title: i18n.t("general.delete") as string,
|
||||
title: i18n.tc("general.delete"),
|
||||
icon: $globals.icons.delete,
|
||||
color: "error",
|
||||
event: "delete",
|
||||
},
|
||||
download: {
|
||||
title: i18n.t("general.download") as string,
|
||||
title: i18n.tc("general.download"),
|
||||
icon: $globals.icons.download,
|
||||
color: undefined,
|
||||
event: "download",
|
||||
},
|
||||
mealplanner: {
|
||||
title: i18n.t("recipe.add-to-plan") as string,
|
||||
title: i18n.tc("recipe.add-to-plan"),
|
||||
icon: $globals.icons.calendar,
|
||||
color: undefined,
|
||||
event: "mealplanner",
|
||||
},
|
||||
shoppingList: {
|
||||
title: i18n.t("recipe.add-to-list") as string,
|
||||
title: i18n.tc("recipe.add-to-list"),
|
||||
icon: $globals.icons.cartCheck,
|
||||
color: undefined,
|
||||
event: "shoppingList",
|
||||
},
|
||||
print: {
|
||||
title: i18n.t("general.print") as string,
|
||||
title: i18n.tc("general.print"),
|
||||
icon: $globals.icons.printer,
|
||||
color: undefined,
|
||||
event: "print",
|
||||
},
|
||||
share: {
|
||||
title: i18n.t("general.share") as string,
|
||||
title: i18n.tc("general.share"),
|
||||
icon: $globals.icons.shareVariant,
|
||||
color: undefined,
|
||||
event: "share",
|
||||
},
|
||||
publicUrl: {
|
||||
title: i18n.tc("recipe.public-link"),
|
||||
icon: $globals.icons.contentCopy,
|
||||
color: undefined,
|
||||
event: "publicUrl",
|
||||
},
|
||||
};
|
||||
|
||||
// Get Default Menu Items Specified in Props
|
||||
|
@ -311,6 +329,8 @@ export default defineComponent({
|
|||
}
|
||||
}
|
||||
|
||||
const { copyText } = useCopy();
|
||||
|
||||
// Note: Print is handled as an event in the parent component
|
||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||
delete: () => {
|
||||
|
@ -328,6 +348,14 @@ export default defineComponent({
|
|||
share: () => {
|
||||
state.shareDialog = true;
|
||||
},
|
||||
publicUrl: () => {
|
||||
if (!props.groupId) {
|
||||
alert.error("Unknown group ID");
|
||||
console.error("prop `groupId` is required when requesting a public URL");
|
||||
return;
|
||||
}
|
||||
copyText(`${window.location.origin}/explore/recipes/${props.groupId}/${props.slug}`);
|
||||
},
|
||||
};
|
||||
|
||||
function contextMenuEventHandler(eventKey: string) {
|
||||
|
@ -344,9 +372,9 @@ export default defineComponent({
|
|||
}
|
||||
|
||||
return {
|
||||
...toRefs(state),
|
||||
shoppingLists,
|
||||
addRecipeToList,
|
||||
...toRefs(state),
|
||||
contextMenuEventHandler,
|
||||
deleteRecipe,
|
||||
addRecipeToPlan,
|
||||
|
|
|
@ -134,7 +134,8 @@ export default defineComponent({
|
|||
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
||||
|
||||
if (data) {
|
||||
state.tokens = data.items ?? [];
|
||||
// @ts-expect-error - TODO: This routes doesn't have pagination, but the type are mismatched.
|
||||
state.tokens = data ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -66,7 +66,7 @@
|
|||
</div>
|
||||
|
||||
<RecipePageComments
|
||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||
v-if="user.id && !recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||
:recipe="recipe"
|
||||
class="px-1 my-4 d-print-none"
|
||||
/>
|
||||
|
@ -89,7 +89,7 @@ import RecipePageScale from "./RecipePageParts/RecipePageScale.vue";
|
|||
import RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue";
|
||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
||||
import { EditorMode, PageMode, usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { EditorMode, PageMode, usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { NoUndefinedField } from "~/types/api";
|
||||
import { Recipe } from "~/types/api-types/recipe";
|
||||
import { useRecipeMeta } from "~/composables/recipes";
|
||||
|
@ -270,7 +270,10 @@ export default defineComponent({
|
|||
const metaData = useRecipeMeta(ref(props.recipe));
|
||||
useMeta(metaData);
|
||||
|
||||
const { user } = usePageUser();
|
||||
|
||||
return {
|
||||
user,
|
||||
api,
|
||||
scale: ref(1),
|
||||
EDITOR_OPTIONS,
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
:max-width="landscape ? null : '50%'"
|
||||
min-height="50"
|
||||
:height="hideImage ? undefined : imageHeight"
|
||||
:src="recipeImage(recipe.id, recipe.image, imageKey)"
|
||||
:src="recipeImageUrl"
|
||||
class="d-print-none"
|
||||
@error="hideImage = true"
|
||||
>
|
||||
|
@ -34,6 +34,8 @@
|
|||
</div>
|
||||
<v-divider></v-divider>
|
||||
<RecipeActionMenu
|
||||
v-if="user.id"
|
||||
:recipe="recipe"
|
||||
:slug="recipe.slug"
|
||||
:locked="user.id !== recipe.userId && recipe.settings.locked"
|
||||
:name="recipe.name"
|
||||
|
@ -52,7 +54,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, useContext, computed, ref } from "@nuxtjs/composition-api";
|
||||
import { defineComponent, useContext, computed, ref, watch } from "@nuxtjs/composition-api";
|
||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||
|
@ -82,7 +84,7 @@ export default defineComponent({
|
|||
const { user } = usePageUser();
|
||||
|
||||
function printRecipe() {
|
||||
print();
|
||||
window.print();
|
||||
}
|
||||
|
||||
const { $vuetify } = useContext();
|
||||
|
@ -92,6 +94,17 @@ export default defineComponent({
|
|||
return $vuetify.breakpoint.xs ? "200" : "400";
|
||||
});
|
||||
|
||||
const recipeImageUrl = computed(() => {
|
||||
return recipeImage(props.recipe.id, props.recipe.image, imageKey.value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => recipeImageUrl.value,
|
||||
() => {
|
||||
hideImage.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
setMode,
|
||||
toggleEditMode,
|
||||
|
@ -106,6 +119,7 @@ export default defineComponent({
|
|||
imageHeight,
|
||||
hideImage,
|
||||
isEditMode,
|
||||
recipeImageUrl,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
hide-details
|
||||
class="pt-0 my-auto py-auto"
|
||||
color="secondary"
|
||||
@change="toolStore.actions.updateOne(recipe.tools[index])"
|
||||
@change="updateTool(index)"
|
||||
>
|
||||
</v-checkbox>
|
||||
<v-list-item-content>
|
||||
|
@ -26,7 +26,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "@nuxtjs/composition-api";
|
||||
import { usePageState } from "~/composables/recipe-page/shared-state";
|
||||
import { usePageState, usePageUser } from "~/composables/recipe-page/shared-state";
|
||||
import { useToolStore } from "~/composables/store";
|
||||
import { NoUndefinedField } from "~/types/api";
|
||||
import { Recipe } from "~/types/api-types/recipe";
|
||||
|
@ -48,12 +48,21 @@ export default defineComponent({
|
|||
},
|
||||
setup(props) {
|
||||
const toolStore = useToolStore();
|
||||
|
||||
const { user } = usePageUser();
|
||||
const { isEditMode } = usePageState(props.recipe.slug);
|
||||
|
||||
function updateTool(index: number) {
|
||||
if (user.id) {
|
||||
toolStore.actions.updateOne(props.recipe.tools[index]);
|
||||
} else {
|
||||
console.log("no user, skipping server update");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolStore,
|
||||
isEditMode,
|
||||
updateTool,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue