mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-07-09 03:04:54 -04: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
@ -2,7 +2,15 @@ import { CommentsApi } from "./recipe-comments";
|
|||||||
import { RecipeShareApi } from "./recipe-share";
|
import { RecipeShareApi } from "./recipe-share";
|
||||||
import { BaseCRUDAPI } from "~/api/_base";
|
import { BaseCRUDAPI } from "~/api/_base";
|
||||||
|
|
||||||
import { Recipe, CreateRecipe, RecipeAsset, CreateRecipeByUrlBulk, ParsedIngredient, UpdateImageResponse, RecipeZipTokenResponse } from "~/types/api-types/recipe";
|
import {
|
||||||
|
Recipe,
|
||||||
|
CreateRecipe,
|
||||||
|
RecipeAsset,
|
||||||
|
CreateRecipeByUrlBulk,
|
||||||
|
ParsedIngredient,
|
||||||
|
UpdateImageResponse,
|
||||||
|
RecipeZipTokenResponse,
|
||||||
|
} from "~/types/api-types/recipe";
|
||||||
import { ApiRequestInstance } from "~/types/api";
|
import { ApiRequestInstance } from "~/types/api";
|
||||||
|
|
||||||
export type Parser = "nlp" | "brute";
|
export type Parser = "nlp" | "brute";
|
||||||
@ -35,8 +43,6 @@ const routes = {
|
|||||||
|
|
||||||
recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`,
|
recipesSlugComments: (slug: string) => `${prefix}/recipes/${slug}/comments`,
|
||||||
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
|
recipesSlugCommentsId: (slug: string, id: number) => `${prefix}/recipes/${slug}/comments/${id}`,
|
||||||
|
|
||||||
recipeShareToken: (token: string) => `${prefix}/recipes/shared/${token}`,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
||||||
@ -110,8 +116,4 @@ export class RecipeAPI extends BaseCRUDAPI<CreateRecipe, Recipe, Recipe> {
|
|||||||
getZipRedirectUrl(recipeSlug: string, token: string) {
|
getZipRedirectUrl(recipeSlug: string, token: string) {
|
||||||
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
|
return `${routes.recipesRecipeSlugExportZip(recipeSlug)}?token=${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getShared(item_id: string) {
|
|
||||||
return await this.requests.get<Recipe>(routes.recipeShareToken(item_id));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
import { ValidatorsApi } from "./public/validators";
|
import { ValidatorsApi } from "./public/validators";
|
||||||
|
import { ExploreApi } from "./public/explore";
|
||||||
|
import { SharedApi } from "./public/shared";
|
||||||
import { ApiRequestInstance } from "~/types/api";
|
import { ApiRequestInstance } from "~/types/api";
|
||||||
|
|
||||||
export class PublicApi {
|
export class PublicApi {
|
||||||
public validators: ValidatorsApi;
|
public validators: ValidatorsApi;
|
||||||
|
public explore: ExploreApi;
|
||||||
|
public shared: SharedApi;
|
||||||
|
|
||||||
constructor(requests: ApiRequestInstance) {
|
constructor(requests: ApiRequestInstance) {
|
||||||
this.validators = new ValidatorsApi(requests);
|
this.validators = new ValidatorsApi(requests);
|
||||||
|
this.explore = new ExploreApi(requests);
|
||||||
|
this.shared = new SharedApi(requests);
|
||||||
|
|
||||||
Object.freeze(this);
|
Object.freeze(this);
|
||||||
}
|
}
|
||||||
|
14
frontend/api/public/explore.ts
Normal file
14
frontend/api/public/explore.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { BaseAPI } from "../_base";
|
||||||
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
|
|
||||||
|
const prefix = "/api";
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
recipe: (groupId: string, recipeSlug: string) => `${prefix}/explore/recipes/${groupId}/${recipeSlug}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ExploreApi extends BaseAPI {
|
||||||
|
async recipe(groupId: string, recipeSlug: string) {
|
||||||
|
return await this.requests.get<Recipe>(routes.recipe(groupId, recipeSlug));
|
||||||
|
}
|
||||||
|
}
|
14
frontend/api/public/shared.ts
Normal file
14
frontend/api/public/shared.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { BaseAPI } from "../_base";
|
||||||
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
|
|
||||||
|
const prefix = "/api";
|
||||||
|
|
||||||
|
const routes = {
|
||||||
|
recipeShareToken: (token: string) => `${prefix}/recipes/shared/${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class SharedApi extends BaseAPI {
|
||||||
|
async getShared(item_id: string) {
|
||||||
|
return await this.requests.get<Recipe>(routes.recipeShareToken(item_id));
|
||||||
|
}
|
||||||
|
}
|
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<div v-if="!open" class="custom-btn-group ma-1">
|
<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">
|
<v-tooltip v-if="!locked" bottom color="info">
|
||||||
<template #activator="{ on, attrs }">
|
<template #activator="{ on, attrs }">
|
||||||
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
<v-btn fab small class="mx-1" color="info" v-bind="attrs" v-on="on" @click="$emit('edit', true)">
|
||||||
@ -39,17 +39,17 @@
|
|||||||
<span> {{ $t("recipe.locked-by-owner") }} </span>
|
<span> {{ $t("recipe.locked-by-owner") }} </span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
|
|
||||||
<ClientOnly>
|
|
||||||
<RecipeContextMenu
|
<RecipeContextMenu
|
||||||
show-print
|
show-print
|
||||||
:menu-top="false"
|
:menu-top="false"
|
||||||
:name="name"
|
:name="recipe.name"
|
||||||
:slug="slug"
|
:group-id="recipe.groupId"
|
||||||
:menu-icon="$globals.icons.mdiDotsHorizontal"
|
:slug="recipe.slug"
|
||||||
|
:menu-icon="$globals.icons.dotsVertical"
|
||||||
fab
|
fab
|
||||||
color="info"
|
color="info"
|
||||||
:card-menu="false"
|
:card-menu="false"
|
||||||
:recipe-id="recipeId"
|
:recipe-id="recipe.id"
|
||||||
:use-items="{
|
:use-items="{
|
||||||
delete: false,
|
delete: false,
|
||||||
edit: false,
|
edit: false,
|
||||||
@ -58,10 +58,10 @@
|
|||||||
shoppingList: true,
|
shoppingList: true,
|
||||||
print: true,
|
print: true,
|
||||||
share: true,
|
share: true,
|
||||||
|
publicUrl: recipe.settings ? recipe.settings.public : false,
|
||||||
}"
|
}"
|
||||||
@print="$emit('print')"
|
@print="$emit('print')"
|
||||||
/>
|
/>
|
||||||
</ClientOnly>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="open" class="custom-btn-group mb-">
|
<div v-if="open" class="custom-btn-group mb-">
|
||||||
<v-btn
|
<v-btn
|
||||||
@ -84,6 +84,7 @@
|
|||||||
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
|
||||||
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
import RecipeContextMenu from "./RecipeContextMenu.vue";
|
||||||
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
import RecipeFavoriteBadge from "./RecipeFavoriteBadge.vue";
|
||||||
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
|
|
||||||
const SAVE_EVENT = "save";
|
const SAVE_EVENT = "save";
|
||||||
const DELETE_EVENT = "delete";
|
const DELETE_EVENT = "delete";
|
||||||
@ -93,6 +94,10 @@ const JSON_EVENT = "json";
|
|||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: { RecipeContextMenu, RecipeFavoriteBadge },
|
components: { RecipeContextMenu, RecipeFavoriteBadge },
|
||||||
props: {
|
props: {
|
||||||
|
recipe: {
|
||||||
|
required: true,
|
||||||
|
type: Object as () => Recipe,
|
||||||
|
},
|
||||||
slug: {
|
slug: {
|
||||||
required: true,
|
required: true,
|
||||||
type: String,
|
type: String,
|
||||||
|
@ -50,6 +50,7 @@
|
|||||||
shoppingList: true,
|
shoppingList: true,
|
||||||
print: false,
|
print: false,
|
||||||
share: true,
|
share: true,
|
||||||
|
publicUrl: false,
|
||||||
}"
|
}"
|
||||||
@delete="$emit('delete', slug)"
|
@delete="$emit('delete', slug)"
|
||||||
/>
|
/>
|
||||||
|
@ -49,6 +49,7 @@
|
|||||||
shoppingList: true,
|
shoppingList: true,
|
||||||
print: false,
|
print: false,
|
||||||
share: true,
|
share: true,
|
||||||
|
publicUrl: false,
|
||||||
}"
|
}"
|
||||||
@deleted="$emit('delete', slug)"
|
@deleted="$emit('delete', slug)"
|
||||||
/>
|
/>
|
||||||
|
@ -104,6 +104,7 @@ import { planTypeOptions } from "~/composables/use-group-mealplan";
|
|||||||
import { ShoppingListSummary } from "~/types/api-types/group";
|
import { ShoppingListSummary } from "~/types/api-types/group";
|
||||||
import { PlanEntryType } from "~/types/api-types/meal-plan";
|
import { PlanEntryType } from "~/types/api-types/meal-plan";
|
||||||
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
import { useAxiosDownloader } from "~/composables/api/use-axios-download";
|
||||||
|
import { useCopy } from "~/composables/use-copy";
|
||||||
|
|
||||||
export interface ContextMenuIncludes {
|
export interface ContextMenuIncludes {
|
||||||
delete: boolean;
|
delete: boolean;
|
||||||
@ -113,6 +114,7 @@ export interface ContextMenuIncludes {
|
|||||||
shoppingList: boolean;
|
shoppingList: boolean;
|
||||||
print: boolean;
|
print: boolean;
|
||||||
share: boolean;
|
share: boolean;
|
||||||
|
publicUrl: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextMenuItem {
|
export interface ContextMenuItem {
|
||||||
@ -137,6 +139,7 @@ export default defineComponent({
|
|||||||
shoppingList: true,
|
shoppingList: true,
|
||||||
print: true,
|
print: true,
|
||||||
share: true,
|
share: true,
|
||||||
|
publicUrl: false,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
// Append items are added at the end of the useItems list
|
// Append items are added at the end of the useItems list
|
||||||
@ -177,6 +180,15 @@ export default defineComponent({
|
|||||||
required: true,
|
required: true,
|
||||||
type: String,
|
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) {
|
setup(props, context) {
|
||||||
const api = useUserApi();
|
const api = useUserApi();
|
||||||
@ -200,47 +212,53 @@ export default defineComponent({
|
|||||||
|
|
||||||
const defaultItems: { [key: string]: ContextMenuItem } = {
|
const defaultItems: { [key: string]: ContextMenuItem } = {
|
||||||
edit: {
|
edit: {
|
||||||
title: i18n.t("general.edit") as string,
|
title: i18n.tc("general.edit"),
|
||||||
icon: $globals.icons.edit,
|
icon: $globals.icons.edit,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "edit",
|
event: "edit",
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
title: i18n.t("general.delete") as string,
|
title: i18n.tc("general.delete"),
|
||||||
icon: $globals.icons.delete,
|
icon: $globals.icons.delete,
|
||||||
color: "error",
|
color: "error",
|
||||||
event: "delete",
|
event: "delete",
|
||||||
},
|
},
|
||||||
download: {
|
download: {
|
||||||
title: i18n.t("general.download") as string,
|
title: i18n.tc("general.download"),
|
||||||
icon: $globals.icons.download,
|
icon: $globals.icons.download,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "download",
|
event: "download",
|
||||||
},
|
},
|
||||||
mealplanner: {
|
mealplanner: {
|
||||||
title: i18n.t("recipe.add-to-plan") as string,
|
title: i18n.tc("recipe.add-to-plan"),
|
||||||
icon: $globals.icons.calendar,
|
icon: $globals.icons.calendar,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "mealplanner",
|
event: "mealplanner",
|
||||||
},
|
},
|
||||||
shoppingList: {
|
shoppingList: {
|
||||||
title: i18n.t("recipe.add-to-list") as string,
|
title: i18n.tc("recipe.add-to-list"),
|
||||||
icon: $globals.icons.cartCheck,
|
icon: $globals.icons.cartCheck,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "shoppingList",
|
event: "shoppingList",
|
||||||
},
|
},
|
||||||
print: {
|
print: {
|
||||||
title: i18n.t("general.print") as string,
|
title: i18n.tc("general.print"),
|
||||||
icon: $globals.icons.printer,
|
icon: $globals.icons.printer,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "print",
|
event: "print",
|
||||||
},
|
},
|
||||||
share: {
|
share: {
|
||||||
title: i18n.t("general.share") as string,
|
title: i18n.tc("general.share"),
|
||||||
icon: $globals.icons.shareVariant,
|
icon: $globals.icons.shareVariant,
|
||||||
color: undefined,
|
color: undefined,
|
||||||
event: "share",
|
event: "share",
|
||||||
},
|
},
|
||||||
|
publicUrl: {
|
||||||
|
title: i18n.tc("recipe.public-link"),
|
||||||
|
icon: $globals.icons.contentCopy,
|
||||||
|
color: undefined,
|
||||||
|
event: "publicUrl",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get Default Menu Items Specified in Props
|
// 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
|
// Note: Print is handled as an event in the parent component
|
||||||
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
const eventHandlers: { [key: string]: () => void | Promise<any> } = {
|
||||||
delete: () => {
|
delete: () => {
|
||||||
@ -328,6 +348,14 @@ export default defineComponent({
|
|||||||
share: () => {
|
share: () => {
|
||||||
state.shareDialog = true;
|
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) {
|
function contextMenuEventHandler(eventKey: string) {
|
||||||
@ -344,9 +372,9 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...toRefs(state),
|
||||||
shoppingLists,
|
shoppingLists,
|
||||||
addRecipeToList,
|
addRecipeToList,
|
||||||
...toRefs(state),
|
|
||||||
contextMenuEventHandler,
|
contextMenuEventHandler,
|
||||||
deleteRecipe,
|
deleteRecipe,
|
||||||
addRecipeToPlan,
|
addRecipeToPlan,
|
||||||
|
@ -134,7 +134,8 @@ export default defineComponent({
|
|||||||
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
const { data } = await userApi.recipes.share.getAll(1, -1, { recipe_id: props.recipeId });
|
||||||
|
|
||||||
if (data) {
|
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>
|
</div>
|
||||||
|
|
||||||
<RecipePageComments
|
<RecipePageComments
|
||||||
v-if="!recipe.settings.disableComments && !isEditForm && !isCookMode"
|
v-if="user.id && !recipe.settings.disableComments && !isEditForm && !isCookMode"
|
||||||
:recipe="recipe"
|
:recipe="recipe"
|
||||||
class="px-1 my-4 d-print-none"
|
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 RecipePageTitleContent from "./RecipePageParts/RecipePageTitleContent.vue";
|
||||||
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
import RecipePageComments from "./RecipePageParts/RecipePageComments.vue";
|
||||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.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 { NoUndefinedField } from "~/types/api";
|
||||||
import { Recipe } from "~/types/api-types/recipe";
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
import { useRecipeMeta } from "~/composables/recipes";
|
import { useRecipeMeta } from "~/composables/recipes";
|
||||||
@ -270,7 +270,10 @@ export default defineComponent({
|
|||||||
const metaData = useRecipeMeta(ref(props.recipe));
|
const metaData = useRecipeMeta(ref(props.recipe));
|
||||||
useMeta(metaData);
|
useMeta(metaData);
|
||||||
|
|
||||||
|
const { user } = usePageUser();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
user,
|
||||||
api,
|
api,
|
||||||
scale: ref(1),
|
scale: ref(1),
|
||||||
EDITOR_OPTIONS,
|
EDITOR_OPTIONS,
|
||||||
|
@ -26,7 +26,7 @@
|
|||||||
:max-width="landscape ? null : '50%'"
|
:max-width="landscape ? null : '50%'"
|
||||||
min-height="50"
|
min-height="50"
|
||||||
:height="hideImage ? undefined : imageHeight"
|
:height="hideImage ? undefined : imageHeight"
|
||||||
:src="recipeImage(recipe.id, recipe.image, imageKey)"
|
:src="recipeImageUrl"
|
||||||
class="d-print-none"
|
class="d-print-none"
|
||||||
@error="hideImage = true"
|
@error="hideImage = true"
|
||||||
>
|
>
|
||||||
@ -34,6 +34,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<RecipeActionMenu
|
<RecipeActionMenu
|
||||||
|
v-if="user.id"
|
||||||
|
:recipe="recipe"
|
||||||
:slug="recipe.slug"
|
:slug="recipe.slug"
|
||||||
:locked="user.id !== recipe.userId && recipe.settings.locked"
|
:locked="user.id !== recipe.userId && recipe.settings.locked"
|
||||||
:name="recipe.name"
|
:name="recipe.name"
|
||||||
@ -52,7 +54,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<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 RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
||||||
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
import RecipeActionMenu from "~/components/Domain/Recipe/RecipeActionMenu.vue";
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
||||||
@ -82,7 +84,7 @@ export default defineComponent({
|
|||||||
const { user } = usePageUser();
|
const { user } = usePageUser();
|
||||||
|
|
||||||
function printRecipe() {
|
function printRecipe() {
|
||||||
print();
|
window.print();
|
||||||
}
|
}
|
||||||
|
|
||||||
const { $vuetify } = useContext();
|
const { $vuetify } = useContext();
|
||||||
@ -92,6 +94,17 @@ export default defineComponent({
|
|||||||
return $vuetify.breakpoint.xs ? "200" : "400";
|
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 {
|
return {
|
||||||
setMode,
|
setMode,
|
||||||
toggleEditMode,
|
toggleEditMode,
|
||||||
@ -106,6 +119,7 @@ export default defineComponent({
|
|||||||
imageHeight,
|
imageHeight,
|
||||||
hideImage,
|
hideImage,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
|
recipeImageUrl,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -13,7 +13,7 @@
|
|||||||
hide-details
|
hide-details
|
||||||
class="pt-0 my-auto py-auto"
|
class="pt-0 my-auto py-auto"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
@change="toolStore.actions.updateOne(recipe.tools[index])"
|
@change="updateTool(index)"
|
||||||
>
|
>
|
||||||
</v-checkbox>
|
</v-checkbox>
|
||||||
<v-list-item-content>
|
<v-list-item-content>
|
||||||
@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent } from "@nuxtjs/composition-api";
|
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 { useToolStore } from "~/composables/store";
|
||||||
import { NoUndefinedField } from "~/types/api";
|
import { NoUndefinedField } from "~/types/api";
|
||||||
import { Recipe } from "~/types/api-types/recipe";
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
@ -48,12 +48,21 @@ export default defineComponent({
|
|||||||
},
|
},
|
||||||
setup(props) {
|
setup(props) {
|
||||||
const toolStore = useToolStore();
|
const toolStore = useToolStore();
|
||||||
|
const { user } = usePageUser();
|
||||||
const { isEditMode } = usePageState(props.recipe.slug);
|
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 {
|
return {
|
||||||
toolStore,
|
toolStore,
|
||||||
isEditMode,
|
isEditMode,
|
||||||
|
updateTool,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,7 +1,15 @@
|
|||||||
import { Ref } from "@nuxtjs/composition-api";
|
import { Ref } from "@nuxtjs/composition-api";
|
||||||
import { Recipe } from "~/types/api-types/recipe";
|
import { Recipe } from "~/types/api-types/recipe";
|
||||||
|
|
||||||
export const useRecipeMeta = (recipe: Ref<Recipe | null>) => {
|
export interface RecipeMeta {
|
||||||
|
title?: string;
|
||||||
|
metaImage?: string;
|
||||||
|
meta: Array<any>;
|
||||||
|
__dangerouslyDisableSanitizers: Array<string>;
|
||||||
|
script: Array<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRecipeMeta = (recipe: Ref<Recipe | null>): (() => RecipeMeta) => {
|
||||||
return () => {
|
return () => {
|
||||||
const imageURL = "";
|
const imageURL = "";
|
||||||
return {
|
return {
|
||||||
|
@ -1,6 +1,21 @@
|
|||||||
import { useClipboard } from "@vueuse/core";
|
import { useClipboard } from "@vueuse/core";
|
||||||
import { alert } from "./use-toast";
|
import { alert } from "./use-toast";
|
||||||
|
|
||||||
|
export function useCopy() {
|
||||||
|
const { copy, copied, isSupported } = useClipboard();
|
||||||
|
|
||||||
|
function copyText(text: string) {
|
||||||
|
if (!isSupported) {
|
||||||
|
alert.error("Clipboard not supported");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copy(text);
|
||||||
|
alert.success("Copied to clipboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { copyText, copied };
|
||||||
|
}
|
||||||
|
|
||||||
export function useCopyList() {
|
export function useCopyList() {
|
||||||
const { copy, isSupported } = useClipboard();
|
const { copy, isSupported } = useClipboard();
|
||||||
|
|
||||||
@ -46,4 +61,3 @@ export function useCopyList() {
|
|||||||
copyMarkdownCheckList,
|
copyMarkdownCheckList,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -353,7 +353,8 @@
|
|||||||
"reset-scale": "Reset Scale",
|
"reset-scale": "Reset Scale",
|
||||||
"decrease-scale-label": "Decrease Scale by 1",
|
"decrease-scale-label": "Decrease Scale by 1",
|
||||||
"increase-scale-label": "Increase Scale by 1",
|
"increase-scale-label": "Increase Scale by 1",
|
||||||
"locked": "Locked"
|
"locked": "Locked",
|
||||||
|
"public-link": "Public Link"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
"advanced-search": "Advanced Search",
|
"advanced-search": "Advanced Search",
|
||||||
|
50
frontend/pages/explore/recipes/_groupId/_slug.vue
Normal file
50
frontend/pages/explore/recipes/_groupId/_slug.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<client-only>
|
||||||
|
<RecipePage v-if="recipe" :recipe="recipe" />
|
||||||
|
</client-only>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent, ref, useAsync, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
|
||||||
|
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
|
||||||
|
import { usePublicApi } from "~/composables/api/api-client";
|
||||||
|
import { useRecipeMeta } from "~/composables/recipes";
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
components: { RecipePage },
|
||||||
|
layout: "basic",
|
||||||
|
setup() {
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const groupId = route.value.params.groupId;
|
||||||
|
const slug = route.value.params.slug;
|
||||||
|
const api = usePublicApi();
|
||||||
|
|
||||||
|
const { meta, title } = useMeta();
|
||||||
|
|
||||||
|
const recipe = useAsync(async () => {
|
||||||
|
const { data, error } = await api.explore.recipe(groupId, slug);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("error loading recipe -> ", error);
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
title.value = data?.name || "";
|
||||||
|
const metaObj = useRecipeMeta(ref(data));
|
||||||
|
meta.value = metaObj().meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipe,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
head: {},
|
||||||
|
});
|
||||||
|
</script>
|
@ -247,6 +247,7 @@
|
|||||||
print: true,
|
print: true,
|
||||||
share: false,
|
share: false,
|
||||||
shoppingList: true,
|
shoppingList: true,
|
||||||
|
publicUrl: false,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
@ -7,7 +7,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { defineComponent, useRoute } from "@nuxtjs/composition-api";
|
import { defineComponent, useRoute } from "@nuxtjs/composition-api";
|
||||||
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
|
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
|
||||||
import { useUserApi } from "~/composables/api";
|
|
||||||
import { useRecipe } from "~/composables/recipes";
|
import { useRecipe } from "~/composables/recipes";
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
@ -15,18 +14,13 @@ export default defineComponent({
|
|||||||
setup() {
|
setup() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const slug = route.value.params.slug;
|
const slug = route.value.params.slug;
|
||||||
const api = useUserApi();
|
|
||||||
|
|
||||||
const { recipe, loading, fetchRecipe } = useRecipe(slug);
|
const { recipe, loading } = useRecipe(slug);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
recipe,
|
recipe,
|
||||||
loading,
|
loading,
|
||||||
fetchRecipe,
|
|
||||||
api,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped></style>
|
|
||||||
|
@ -1,401 +1,49 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container
|
|
||||||
:class="{
|
|
||||||
'pa-0': $vuetify.breakpoint.smAndDown,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<v-card v-if="skeleton" :color="`white ${false ? 'darken-2' : 'lighten-4'}`" class="pa-3">
|
|
||||||
<v-skeleton-loader class="mx-auto" height="700px" type="card"></v-skeleton-loader>
|
|
||||||
</v-card>
|
|
||||||
<v-card v-else-if="recipe" class="d-print-none">
|
|
||||||
<!-- Recipe Header -->
|
|
||||||
<div class="d-flex justify-end flex-wrap align-stretch">
|
|
||||||
<v-card v-if="!enableLandscape" width="50%" flat class="d-flex flex-column justify-center align-center">
|
|
||||||
<v-card-text>
|
|
||||||
<v-card-title class="headline pa-0 flex-column align-center">
|
|
||||||
{{ recipe.name }}
|
|
||||||
<RecipeRating :key="recipe.slug" :value="recipe.rating" :name="recipe.name" :slug="recipe.slug" />
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider class="my-2"></v-divider>
|
|
||||||
<SafeMarkdown :source="recipe.description"> </SafeMarkdown>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
<div class="d-flex justify-center mt-5">
|
|
||||||
<RecipeTimeCard
|
|
||||||
class="d-flex justify-center flex-wrap"
|
|
||||||
:class="true ? undefined : 'force-bottom'"
|
|
||||||
:prep-time="recipe.prepTime"
|
|
||||||
:total-time="recipe.totalTime"
|
|
||||||
:perform-time="recipe.performTime"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
<v-img
|
|
||||||
:key="imageKey"
|
|
||||||
:max-width="enableLandscape ? null : '50%'"
|
|
||||||
:height="hideImage ? '50' : imageHeight"
|
|
||||||
:src="recipeImage(recipe.id, recipe.image, imageKey)"
|
|
||||||
class="d-print-none"
|
|
||||||
@error="hideImage = true"
|
|
||||||
>
|
|
||||||
<RecipeTimeCard
|
|
||||||
v-if="enableLandscape"
|
|
||||||
:class="true ? undefined : 'force-bottom'"
|
|
||||||
:prep-time="recipe.prepTime"
|
|
||||||
:total-time="recipe.totalTime"
|
|
||||||
:perform-time="recipe.performTime"
|
|
||||||
/>
|
|
||||||
</v-img>
|
|
||||||
</div>
|
|
||||||
<v-divider></v-divider>
|
|
||||||
|
|
||||||
<!-- Editors -->
|
|
||||||
<div>
|
<div>
|
||||||
<v-card-text
|
<client-only>
|
||||||
:class="{
|
<RecipePage v-if="recipe" :recipe="recipe" />
|
||||||
'px-2': $vuetify.breakpoint.smAndDown,
|
</client-only>
|
||||||
}"
|
|
||||||
>
|
|
||||||
<!-- Recipe Title Section -->
|
|
||||||
<template v-if="!form && enableLandscape">
|
|
||||||
<v-card-title class="pa-0 ma-0 headline">
|
|
||||||
{{ recipe.name }}
|
|
||||||
</v-card-title>
|
|
||||||
<SafeMarkdown :source="recipe.description"> </SafeMarkdown>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="form">
|
|
||||||
<v-text-field
|
|
||||||
v-model="recipe.name"
|
|
||||||
class="my-3"
|
|
||||||
:label="$t('recipe.recipe-name')"
|
|
||||||
:rules="[validators.required]"
|
|
||||||
>
|
|
||||||
</v-text-field>
|
|
||||||
|
|
||||||
<div class="d-flex flex-wrap">
|
|
||||||
<v-text-field v-model="recipe.totalTime" class="mx-2" :label="$t('recipe.total-time')"></v-text-field>
|
|
||||||
<v-text-field v-model="recipe.prepTime" class="mx-2" :label="$t('recipe.prep-time')"></v-text-field>
|
|
||||||
<v-text-field v-model="recipe.performTime" class="mx-2" :label="$t('recipe.perform-time')"></v-text-field>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-textarea v-model="recipe.description" auto-grow min-height="100" :label="$t('recipe.description')">
|
|
||||||
</v-textarea>
|
|
||||||
<v-text-field v-model="recipe.recipeYield" dense :label="$t('recipe.servings')"> </v-text-field>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div class="d-flex justify-space-between align-center pb-3">
|
|
||||||
<v-tooltip v-if="!form" small top color="secondary darken-1">
|
|
||||||
<template #activator="{ on, attrs }">
|
|
||||||
<v-btn
|
|
||||||
v-if="recipe.recipeYield"
|
|
||||||
dense
|
|
||||||
small
|
|
||||||
:hover="false"
|
|
||||||
type="label"
|
|
||||||
:ripple="false"
|
|
||||||
elevation="0"
|
|
||||||
color="secondary darken-1"
|
|
||||||
class="rounded-sm static"
|
|
||||||
v-bind="attrs"
|
|
||||||
@click="scale = 1"
|
|
||||||
v-on="on"
|
|
||||||
>
|
|
||||||
{{ scaledYield }}
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<span> Reset Scale </span>
|
|
||||||
</v-tooltip>
|
|
||||||
|
|
||||||
<template v-if="!recipe.settings.disableAmount && !form">
|
|
||||||
<v-btn color="secondary darken-1" class="mx-1" small @click="scale > 1 ? scale-- : null">
|
|
||||||
<v-icon>
|
|
||||||
{{ $globals.icons.minus }}
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn color="secondary darken-1" small @click="scale++">
|
|
||||||
<v-icon>
|
|
||||||
{{ $globals.icons.createAlt }}
|
|
||||||
</v-icon>
|
|
||||||
</v-btn>
|
|
||||||
</template>
|
|
||||||
<v-spacer></v-spacer>
|
|
||||||
|
|
||||||
<RecipeRating
|
|
||||||
v-if="enableLandscape"
|
|
||||||
:key="recipe.slug"
|
|
||||||
:value="recipe.rating"
|
|
||||||
:name="recipe.name"
|
|
||||||
:slug="recipe.slug"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-row>
|
|
||||||
<v-col cols="12" sm="12" md="4" lg="4">
|
|
||||||
<RecipeIngredients
|
|
||||||
v-if="!form"
|
|
||||||
:value="recipe.recipeIngredient"
|
|
||||||
:scale="scale"
|
|
||||||
:disable-amount="recipe.settings.disableAmount"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Recipe Tools Display -->
|
|
||||||
<div v-if="!form && recipe.tools && recipe.tools.length > 0">
|
|
||||||
<h2 class="mb-2 mt-4">Required Tools</h2>
|
|
||||||
<v-list-item v-for="(tool, index) in recipe.tools" :key="index" dense>
|
|
||||||
<v-checkbox
|
|
||||||
v-model="recipe.tools[index].onHand"
|
|
||||||
hide-details
|
|
||||||
class="pt-0 my-auto py-auto"
|
|
||||||
color="secondary"
|
|
||||||
@change="updateTool(recipe.tools[index])"
|
|
||||||
>
|
|
||||||
</v-checkbox>
|
|
||||||
<v-list-item-content>
|
|
||||||
{{ tool.name }}
|
|
||||||
</v-list-item-content>
|
|
||||||
</v-list-item>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$vuetify.breakpoint.mdAndUp" class="mt-5">
|
|
||||||
<!-- Recipe Categories -->
|
|
||||||
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
|
|
||||||
<v-card-title class="py-2">
|
|
||||||
{{ $t("recipe.categories") }}
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider class="mx-2"></v-divider>
|
|
||||||
<v-card-text>
|
|
||||||
<RecipeChips :items="recipe.recipeCategory" />
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- Recipe Tags -->
|
|
||||||
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
|
|
||||||
<v-card-title class="py-2">
|
|
||||||
{{ $t("tag.tags") }}
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider class="mx-2"></v-divider>
|
|
||||||
<v-card-text>
|
|
||||||
<RecipeChips :items="recipe.tags" url-prefix="tags" />
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<RecipeNutrition
|
|
||||||
v-if="recipe.settings.showNutrition"
|
|
||||||
v-model="recipe.nutrition"
|
|
||||||
class="mt-10"
|
|
||||||
:edit="form"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-col>
|
|
||||||
<v-divider v-if="$vuetify.breakpoint.mdAndUp" class="my-divider" :vertical="true"></v-divider>
|
|
||||||
|
|
||||||
<v-col cols="12" sm="12" md="8" lg="8">
|
|
||||||
<RecipeInstructions
|
|
||||||
v-model="recipe.recipeInstructions"
|
|
||||||
:ingredients="recipe.recipeIngredient"
|
|
||||||
:disable-amount="recipe.settings.disableAmount"
|
|
||||||
:edit="form"
|
|
||||||
public
|
|
||||||
:assets="recipe.assets"
|
|
||||||
:recipe-id="recipe.id"
|
|
||||||
:recipe-slug="recipe.slug"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- TODO: Somehow fix duplicate code for mobile/desktop -->
|
|
||||||
<div v-if="!$vuetify.breakpoint.mdAndUp" class="mt-5">
|
|
||||||
<!-- Recipe Categories -->
|
|
||||||
<v-card v-if="recipe.recipeCategory.length > 0 || form" class="mt-2">
|
|
||||||
<v-card-title class="py-2">
|
|
||||||
{{ $t("recipe.categories") }}
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider class="mx-2"></v-divider>
|
|
||||||
<v-card-text>
|
|
||||||
<RecipeChips :items="recipe.recipeCategory" />
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<!-- Recipe Tags -->
|
|
||||||
<v-card v-if="recipe.tags.length > 0 || form" class="mt-2">
|
|
||||||
<v-card-title class="py-2">
|
|
||||||
{{ $t("tag.tags") }}
|
|
||||||
</v-card-title>
|
|
||||||
<v-divider class="mx-2"></v-divider>
|
|
||||||
<v-card-text>
|
|
||||||
<RecipeChips :items="recipe.tags" url-prefix="tags" />
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
|
|
||||||
<RecipeNutrition
|
|
||||||
v-if="recipe.settings.showNutrition"
|
|
||||||
v-model="recipe.nutrition"
|
|
||||||
class="mt-10"
|
|
||||||
:edit="form"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecipeNotes v-model="recipe.notes" :edit="form" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
|
|
||||||
<v-card-actions class="justify-end">
|
|
||||||
<v-btn
|
|
||||||
v-if="recipe.orgURL"
|
|
||||||
dense
|
|
||||||
small
|
|
||||||
:hover="false"
|
|
||||||
type="label"
|
|
||||||
:ripple="false"
|
|
||||||
elevation="0"
|
|
||||||
:href="recipe.orgURL"
|
|
||||||
color="secondary darken-1"
|
|
||||||
target="_blank"
|
|
||||||
class="rounded-sm mr-4"
|
|
||||||
>
|
|
||||||
{{ $t("recipe.original-url") }}
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card-text>
|
|
||||||
</div>
|
|
||||||
</v-card>
|
|
||||||
<RecipePrintView v-if="recipe" :recipe="recipe" />
|
|
||||||
</v-container>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { defineComponent, ref, useAsync, useMeta, useRoute, useRouter } from "@nuxtjs/composition-api";
|
||||||
computed,
|
import RecipePage from "~/components/Domain/Recipe/RecipePage/RecipePage.vue";
|
||||||
defineComponent,
|
import { usePublicApi } from "~/composables/api/api-client";
|
||||||
reactive,
|
import { useRecipeMeta } from "~/composables/recipes";
|
||||||
toRefs,
|
|
||||||
useAsync,
|
|
||||||
useContext,
|
|
||||||
useMeta,
|
|
||||||
useRoute,
|
|
||||||
} from "@nuxtjs/composition-api";
|
|
||||||
// import { useRecipeMeta } from "~/composables/recipes";
|
|
||||||
import { useStaticRoutes, useUserApi } from "~/composables/api";
|
|
||||||
import RecipeChips from "~/components/Domain/Recipe/RecipeChips.vue";
|
|
||||||
import RecipeIngredients from "~/components/Domain/Recipe/RecipeIngredients.vue";
|
|
||||||
import RecipeRating from "~/components/Domain/Recipe/RecipeRating.vue";
|
|
||||||
import RecipeTimeCard from "~/components/Domain/Recipe/RecipeTimeCard.vue";
|
|
||||||
import RecipeNutrition from "~/components/Domain/Recipe/RecipeNutrition.vue";
|
|
||||||
import RecipeInstructions from "~/components/Domain/Recipe/RecipeInstructions.vue";
|
|
||||||
import RecipeNotes from "~/components/Domain/Recipe/RecipeNotes.vue";
|
|
||||||
import RecipePrintView from "~/components/Domain/Recipe/RecipePrintView.vue";
|
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: { RecipePage },
|
||||||
RecipeChips,
|
|
||||||
RecipeIngredients,
|
|
||||||
RecipeInstructions,
|
|
||||||
RecipeNotes,
|
|
||||||
RecipeNutrition,
|
|
||||||
RecipePrintView,
|
|
||||||
RecipeRating,
|
|
||||||
RecipeTimeCard,
|
|
||||||
},
|
|
||||||
layout: "basic",
|
layout: "basic",
|
||||||
setup() {
|
setup() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const id = route.value.params.id;
|
const router = useRouter();
|
||||||
const api = useUserApi();
|
const recipeId = route.value.params.id;
|
||||||
|
const api = usePublicApi();
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
form: false,
|
|
||||||
scale: 1,
|
|
||||||
hideImage: false,
|
|
||||||
imageKey: 1,
|
|
||||||
loadFailed: false,
|
|
||||||
skeleton: false,
|
|
||||||
jsonEditor: false,
|
|
||||||
jsonEditorOptions: {
|
|
||||||
mode: "code",
|
|
||||||
search: false,
|
|
||||||
mainMenuBar: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { recipeImage } = useStaticRoutes();
|
|
||||||
const { meta, title } = useMeta();
|
const { meta, title } = useMeta();
|
||||||
|
|
||||||
const recipe = useAsync(async () => {
|
const recipe = useAsync(async () => {
|
||||||
const { data } = await api.recipes.getShared(id);
|
const { data, error } = await api.shared.getShared(recipeId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("error loading recipe -> ", error);
|
||||||
|
router.push("/");
|
||||||
|
}
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
if (data && data !== undefined) {
|
title.value = data?.name || "";
|
||||||
const imageURL = data.id ? recipeImage(data.id) : undefined;
|
const metaObj = useRecipeMeta(ref(data));
|
||||||
title.value = data.name;
|
meta.value = metaObj().meta;
|
||||||
|
|
||||||
meta.value = [
|
|
||||||
{ hid: "og:title", property: "og:title", content: data.name ?? "" },
|
|
||||||
{
|
|
||||||
hid: "og:desc",
|
|
||||||
property: "og:description",
|
|
||||||
content: data.description ?? "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hid: "og-image",
|
|
||||||
property: "og:image",
|
|
||||||
content: imageURL ?? "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hid: "twitter:title",
|
|
||||||
property: "twitter:title",
|
|
||||||
content: data.name ?? "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hid: "twitter:desc",
|
|
||||||
property: "twitter:description",
|
|
||||||
content: data.description ?? "",
|
|
||||||
},
|
|
||||||
{ hid: "t-type", name: "twitter:card", content: "summary_large_image" },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const { $vuetify } = useContext();
|
|
||||||
|
|
||||||
const enableLandscape = computed(() => {
|
|
||||||
const preferLandscape = recipe.value?.settings?.landscapeView;
|
|
||||||
const smallScreen = !$vuetify.breakpoint.smAndUp;
|
|
||||||
|
|
||||||
if (preferLandscape) {
|
|
||||||
return true;
|
|
||||||
} else if (smallScreen) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const scaledYield = computed(() => {
|
|
||||||
const regMatchNum = /\d+/;
|
|
||||||
const yieldString = recipe.value?.recipeYield;
|
|
||||||
const num = yieldString?.match(regMatchNum);
|
|
||||||
|
|
||||||
if (num && num?.length > 0) {
|
|
||||||
const yieldAsInt = parseInt(num[0]);
|
|
||||||
return yieldString?.replace(num[0], String(yieldAsInt * state.scale));
|
|
||||||
}
|
|
||||||
|
|
||||||
return recipe.value?.recipeYield;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...toRefs(state),
|
|
||||||
recipe,
|
recipe,
|
||||||
recipeImage,
|
|
||||||
scaledYield,
|
|
||||||
enableLandscape,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
head: {},
|
head: {},
|
||||||
computed: {
|
|
||||||
imageHeight() {
|
|
||||||
return this.$vuetify.breakpoint.xs ? "200" : "400";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
@ -1,6 +1,20 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
from . import admin, app, auth, comments, groups, organizers, parser, recipe, shared, unit_and_foods, users, validators
|
from . import (
|
||||||
|
admin,
|
||||||
|
app,
|
||||||
|
auth,
|
||||||
|
comments,
|
||||||
|
explore,
|
||||||
|
groups,
|
||||||
|
organizers,
|
||||||
|
parser,
|
||||||
|
recipe,
|
||||||
|
shared,
|
||||||
|
unit_and_foods,
|
||||||
|
users,
|
||||||
|
validators,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api")
|
router = APIRouter(prefix="/api")
|
||||||
|
|
||||||
@ -16,3 +30,4 @@ router.include_router(parser.router)
|
|||||||
router.include_router(unit_and_foods.router)
|
router.include_router(unit_and_foods.router)
|
||||||
router.include_router(admin.router)
|
router.include_router(admin.router)
|
||||||
router.include_router(validators.router)
|
router.include_router(validators.router)
|
||||||
|
router.include_router(explore.router)
|
||||||
|
7
mealie/routes/explore/__init__.py
Normal file
7
mealie/routes/explore/__init__.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from . import controller_public_recipes
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
router.include_router(controller_public_recipes.router)
|
25
mealie/routes/explore/controller_public_recipes.py
Normal file
25
mealie/routes/explore/controller_public_recipes.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.routes._base import controller
|
||||||
|
from mealie.routes._base.base_controllers import BasePublicController
|
||||||
|
from mealie.schema.recipe import Recipe
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/explore", tags=["Explore: Recipes"])
|
||||||
|
|
||||||
|
|
||||||
|
@controller(router)
|
||||||
|
class PublicRecipesController(BasePublicController):
|
||||||
|
@router.get("/recipes/{group_id}/{recipe_slug}", response_model=Recipe)
|
||||||
|
def get_recipe(self, group_id: UUID4, recipe_slug: str) -> Recipe:
|
||||||
|
group = self.repos.groups.get_one(group_id)
|
||||||
|
|
||||||
|
if not group or group.preferences.private_group:
|
||||||
|
raise HTTPException(404, "group not found")
|
||||||
|
|
||||||
|
recipe = self.repos.recipes.by_group(group_id).get_one(recipe_slug)
|
||||||
|
|
||||||
|
if not recipe or not recipe.settings.public:
|
||||||
|
raise HTTPException(404, "recipe not found")
|
||||||
|
|
||||||
|
return recipe
|
@ -1,10 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import UUID4
|
from pydantic import UUID4
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
from mealie.db.db_setup import generate_session
|
from mealie.db.db_setup import generate_session
|
||||||
from mealie.repos.all_repositories import get_repositories
|
from mealie.repos.all_repositories import get_repositories
|
||||||
from mealie.schema.recipe import Recipe
|
from mealie.schema.recipe import Recipe
|
||||||
|
from mealie.schema.response import ErrorResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@ -16,6 +17,6 @@ def get_shared_recipe(token_id: UUID4, session: Session = Depends(generate_sessi
|
|||||||
token_summary = db.recipe_share_tokens.get_one(token_id)
|
token_summary = db.recipe_share_tokens.get_one(token_id)
|
||||||
|
|
||||||
if token_summary is None:
|
if token_summary is None:
|
||||||
return None
|
raise HTTPException(status_code=404, detail=ErrorResponse.respond("Token Not Found"))
|
||||||
|
|
||||||
return token_summary.recipe
|
return token_summary.recipe
|
||||||
|
@ -0,0 +1,62 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import UUID4
|
||||||
|
|
||||||
|
from mealie.repos.repository_factory import AllRepositories
|
||||||
|
from mealie.schema.recipe.recipe import Recipe
|
||||||
|
from tests.utils.fixture_schemas import TestUser
|
||||||
|
|
||||||
|
|
||||||
|
class Routes:
|
||||||
|
base = "/api/explore/recipes"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def recipe(groud_id: str | UUID4, recipe_slug: str | UUID4) -> str:
|
||||||
|
return f"{Routes.base}/{groud_id}/{recipe_slug}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class PublicRecipeTestCase:
|
||||||
|
private_group: bool
|
||||||
|
public_recipe: bool
|
||||||
|
status_code: int
|
||||||
|
error: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"test_case",
|
||||||
|
(
|
||||||
|
PublicRecipeTestCase(private_group=False, public_recipe=True, status_code=200, error=None),
|
||||||
|
PublicRecipeTestCase(private_group=True, public_recipe=True, status_code=404, error="group not found"),
|
||||||
|
PublicRecipeTestCase(private_group=False, public_recipe=False, status_code=404, error="recipe not found"),
|
||||||
|
),
|
||||||
|
ids=("is public", "group private", "recipe private"),
|
||||||
|
)
|
||||||
|
def test_public_recipe_success(
|
||||||
|
api_client: TestClient,
|
||||||
|
unique_user: TestUser,
|
||||||
|
random_recipe: Recipe,
|
||||||
|
database: AllRepositories,
|
||||||
|
test_case: PublicRecipeTestCase,
|
||||||
|
):
|
||||||
|
group = database.groups.get_one(unique_user.group_id)
|
||||||
|
group.preferences.private_group = test_case.private_group
|
||||||
|
database.group_preferences.update(group.id, group.preferences)
|
||||||
|
|
||||||
|
# Set Recipe `settings.public` attribute
|
||||||
|
random_recipe.settings.public = test_case.public_recipe
|
||||||
|
database.recipes.update(random_recipe.slug, random_recipe)
|
||||||
|
|
||||||
|
# Try to access recipe
|
||||||
|
response = api_client.get(Routes.recipe(random_recipe.group_id, random_recipe.slug))
|
||||||
|
assert response.status_code == test_case.status_code
|
||||||
|
|
||||||
|
if test_case.error:
|
||||||
|
assert response.json()["detail"] == test_case.error
|
||||||
|
return
|
||||||
|
|
||||||
|
as_json = response.json()
|
||||||
|
assert as_json["name"] == random_recipe.name
|
||||||
|
assert as_json["slug"] == random_recipe.slug
|
Loading…
x
Reference in New Issue
Block a user