From 2c5e5a8421e7bc9337fb93309c9f986abee26054 Mon Sep 17 00:00:00 2001 From: Michael Genson <71845777+michael-genson@users.noreply.github.com> Date: Thu, 14 Sep 2023 09:01:24 -0500 Subject: [PATCH] feat: Public Recipe Browser (#2525) * fixed incorrect var ref * added public recipe pagination route * refactored frontend public/explore API * fixed broken public cards * hid context menu from cards when public * fixed public app header * fixed random recipe * added public food, category, tag, and tool routes * not sure why I thought that would work * added public organizer/foods stores * disabled clicking on tags/categories * added public link to profile page * linting * force a 404 if the group slug is missing or invalid * oops * refactored to fit sidebar into explore * fixed invalid logic for app header * removed most sidebar options from public * added backend routes for public cookbooks * added explore cookbook pages/apis * codegen * added backend tests * lint * fixes v-for keys * I do not understand but sure why not --------- Co-authored-by: Hayden <64056131+hay-kot@users.noreply.github.com> --- .../Domain/Cookbook/CookbookPage.vue | 74 +++ .../components/Domain/Recipe/RecipeCard.vue | 16 +- .../Domain/Recipe/RecipeCardMobile.vue | 19 +- .../Domain/Recipe/RecipeCardSection.vue | 15 +- .../components/Domain/Recipe/RecipeChips.vue | 10 +- .../Domain/Recipe/RecipeContextMenu.vue | 20 +- .../Domain/Recipe/RecipeExplorerPage.vue | 495 ++++++++++++++++++ frontend/components/Layout/DefaultLayout.vue | 216 ++++++++ .../Layout/{ => LayoutParts}/AppFooter.vue | 3 +- .../Layout/{ => LayoutParts}/AppHeader.vue | 16 +- .../Layout/{ => LayoutParts}/AppSidebar.vue | 161 +++--- .../Layout/{ => LayoutParts}/TheSnackbar.vue | 0 frontend/composables/api/api-client.ts | 9 + .../partials/use-actions-factory.ts | 59 ++- frontend/composables/recipes/use-recipes.ts | 15 +- .../composables/store/use-category-store.ts | 26 +- frontend/composables/store/use-food-store.ts | 21 +- frontend/composables/store/use-tag-store.ts | 25 +- frontend/composables/store/use-tool-store.ts | 25 +- frontend/composables/use-group-cookbooks.ts | 48 +- frontend/lang/messages/en-US.json | 3 +- frontend/layouts/admin.vue | 31 +- frontend/layouts/basic.vue | 8 +- frontend/layouts/blank.vue | 2 +- frontend/layouts/default.vue | 194 +------ frontend/layouts/explore.vue | 13 + frontend/lib/api/base/base-clients.ts | 19 +- frontend/lib/api/client-public.ts | 11 +- frontend/lib/api/public/explore.ts | 29 +- frontend/lib/api/public/explore/cookbooks.ts | 19 + frontend/lib/api/public/explore/foods.ts | 19 + frontend/lib/api/public/explore/organizers.ts | 41 ++ frontend/lib/api/public/explore/recipes.ts | 19 + frontend/pages/cookbooks/_slug.vue | 62 +-- .../explore/cookbooks/_groupSlug/_slug.vue | 23 + .../_groupSlug/{_slug.vue => _recipeSlug.vue} | 14 +- .../explore/recipes/_groupSlug/index.vue | 42 ++ frontend/pages/index.vue | 487 +---------------- frontend/pages/user/profile/index.vue | 70 ++- frontend/types/application-types.ts | 3 +- frontend/types/components.d.ts | 10 +- mealie/core/dependencies/dependencies.py | 13 +- mealie/routes/_base/base_controllers.py | 12 +- mealie/routes/explore/__init__.py | 16 +- .../explore/controller_public_cookbooks.py | 53 ++ .../routes/explore/controller_public_foods.py | 38 ++ .../explore/controller_public_organizers.py | 99 ++++ .../explore/controller_public_recipes.py | 81 ++- mealie/routes/recipe/recipe_crud_routes.py | 2 +- .../public_recipe_tests.py | 62 --- .../test_public_cookbooks.py | 151 ++++++ .../test_public_foods.py | 69 +++ .../test_public_organizers.py | 142 +++++ .../test_public_recipes.py | 167 ++++++ tests/utils/api_routes/__init__.py | 55 ++ 55 files changed, 2399 insertions(+), 953 deletions(-) create mode 100644 frontend/components/Domain/Cookbook/CookbookPage.vue create mode 100644 frontend/components/Domain/Recipe/RecipeExplorerPage.vue create mode 100644 frontend/components/Layout/DefaultLayout.vue rename frontend/components/Layout/{ => LayoutParts}/AppFooter.vue (97%) rename frontend/components/Layout/{ => LayoutParts}/AppHeader.vue (83%) rename frontend/components/Layout/{ => LayoutParts}/AppSidebar.vue (50%) rename frontend/components/Layout/{ => LayoutParts}/TheSnackbar.vue (100%) create mode 100644 frontend/layouts/explore.vue create mode 100644 frontend/lib/api/public/explore/cookbooks.ts create mode 100644 frontend/lib/api/public/explore/foods.ts create mode 100644 frontend/lib/api/public/explore/organizers.ts create mode 100644 frontend/lib/api/public/explore/recipes.ts create mode 100644 frontend/pages/explore/cookbooks/_groupSlug/_slug.vue rename frontend/pages/explore/recipes/_groupSlug/{_slug.vue => _recipeSlug.vue} (75%) create mode 100644 frontend/pages/explore/recipes/_groupSlug/index.vue create mode 100644 mealie/routes/explore/controller_public_cookbooks.py create mode 100644 mealie/routes/explore/controller_public_foods.py create mode 100644 mealie/routes/explore/controller_public_organizers.py delete mode 100644 tests/integration_tests/public_explorer_tests/public_recipe_tests.py create mode 100644 tests/integration_tests/public_explorer_tests/test_public_cookbooks.py create mode 100644 tests/integration_tests/public_explorer_tests/test_public_foods.py create mode 100644 tests/integration_tests/public_explorer_tests/test_public_organizers.py create mode 100644 tests/integration_tests/public_explorer_tests/test_public_recipes.py diff --git a/frontend/components/Domain/Cookbook/CookbookPage.vue b/frontend/components/Domain/Cookbook/CookbookPage.vue new file mode 100644 index 000000000000..2afb33e7722a --- /dev/null +++ b/frontend/components/Domain/Cookbook/CookbookPage.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend/components/Domain/Recipe/RecipeCard.vue b/frontend/components/Domain/Recipe/RecipeCard.vue index 09ae988604e8..e13bd40d4f21 100644 --- a/frontend/components/Domain/Recipe/RecipeCard.vue +++ b/frontend/components/Domain/Recipe/RecipeCard.vue @@ -4,7 +4,7 @@ @@ -39,7 +39,10 @@ + + { return $auth.loggedIn; }); + const recipeRoute = computed(() => { + return loggedIn.value ? `/recipe/${props.slug}` : `/explore/recipes/${props.groupSlug}/${props.slug}`; + }); + return { loggedIn, + recipeRoute, }; }, }); diff --git a/frontend/components/Domain/Recipe/RecipeCardMobile.vue b/frontend/components/Domain/Recipe/RecipeCardMobile.vue index 507a19696030..57e7be9b3a73 100644 --- a/frontend/components/Domain/Recipe/RecipeCardMobile.vue +++ b/frontend/components/Domain/Recipe/RecipeCardMobile.vue @@ -4,7 +4,7 @@ :ripple="false" :class="isFlat ? 'mx-auto flat' : 'mx-auto'" hover - :to="$listeners.selected ? undefined : `/recipe/${slug}`" + :to="$listeners.selected ? undefined : recipeRoute" @click="$emit('selected')" > @@ -40,7 +40,7 @@ + + + { return $auth.loggedIn; }); + const recipeRoute = computed(() => { + return loggedIn.value ? `/recipe/${props.slug}` : `/explore/recipes/${props.groupSlug}/${props.slug}`; + }); + return { loggedIn, + recipeRoute, }; }, }); diff --git a/frontend/components/Domain/Recipe/RecipeCardSection.vue b/frontend/components/Domain/Recipe/RecipeCardSection.vue index 30211b7f1d2d..5442ecd2e23f 100644 --- a/frontend/components/Domain/Recipe/RecipeCardSection.vue +++ b/frontend/components/Domain/Recipe/RecipeCardSection.vue @@ -76,6 +76,7 @@ Recipe[], default: () => [], @@ -184,7 +190,10 @@ export default defineComponent({ shuffle: "shuffle", }; - const { $globals, $vuetify } = useContext(); + const { $auth, $globals, $vuetify } = useContext(); + const loggedIn = computed(() => { + return $auth.loggedIn; + }); const useMobileCards = computed(() => { return $vuetify.breakpoint.smAndDown || preferences.value.useMobileCards; }); @@ -202,7 +211,7 @@ export default defineComponent({ if (props.recipes.length > 0) { const recipe = props.recipes[Math.floor(Math.random() * props.recipes.length)]; if (recipe.slug !== undefined) { - router.push(`/recipe/${recipe.slug}`); + router.push(loggedIn.value ? `/recipe/${recipe.slug}` : `/explore/recipes/${props.groupSlug}/${recipe.slug}`); } } } @@ -213,7 +222,7 @@ export default defineComponent({ const ready = ref(false); const loading = ref(false); - const { fetchMore } = useLazyRecipes(); + const { fetchMore } = useLazyRecipes(loggedIn.value ? null : props.groupSlug); const queryFilter = computed(() => { const orderBy = props.query?.orderBy || preferences.value.orderBy; diff --git a/frontend/components/Domain/Recipe/RecipeChips.vue b/frontend/components/Domain/Recipe/RecipeChips.vue index b3812c78d87e..1c8c46306bd2 100644 --- a/frontend/components/Domain/Recipe/RecipeChips.vue +++ b/frontend/components/Domain/Recipe/RecipeChips.vue @@ -9,7 +9,7 @@ color="accent" :small="small" dark - :to="`/?${urlPrefix}=${category.id}`" + :to=" loggedIn ? `/?${urlPrefix}=${category.id}` : undefined" > {{ truncateText(category.name) }} @@ -17,7 +17,7 @@ + + diff --git a/frontend/components/Layout/DefaultLayout.vue b/frontend/components/Layout/DefaultLayout.vue new file mode 100644 index 000000000000..b0071bc95f12 --- /dev/null +++ b/frontend/components/Layout/DefaultLayout.vue @@ -0,0 +1,216 @@ + + + diff --git a/frontend/components/Layout/AppFooter.vue b/frontend/components/Layout/LayoutParts/AppFooter.vue similarity index 97% rename from frontend/components/Layout/AppFooter.vue rename to frontend/components/Layout/LayoutParts/AppFooter.vue index 510a47431cc6..3042076a9193 100644 --- a/frontend/components/Layout/AppFooter.vue +++ b/frontend/components/Layout/LayoutParts/AppFooter.vue @@ -12,7 +12,7 @@ - + - diff --git a/frontend/components/Layout/AppHeader.vue b/frontend/components/Layout/LayoutParts/AppHeader.vue similarity index 83% rename from frontend/components/Layout/AppHeader.vue rename to frontend/components/Layout/LayoutParts/AppHeader.vue index 1f5feb937862..5af4088d9c09 100644 --- a/frontend/components/Layout/AppHeader.vue +++ b/frontend/components/Layout/LayoutParts/AppHeader.vue @@ -1,14 +1,14 @@ \ No newline at end of file + diff --git a/frontend/layouts/blank.vue b/frontend/layouts/blank.vue index 10c5973e1c1c..c9bccf58291e 100644 --- a/frontend/layouts/blank.vue +++ b/frontend/layouts/blank.vue @@ -20,7 +20,7 @@ diff --git a/frontend/layouts/explore.vue b/frontend/layouts/explore.vue new file mode 100644 index 000000000000..6bc10eadd0a6 --- /dev/null +++ b/frontend/layouts/explore.vue @@ -0,0 +1,13 @@ + + + + diff --git a/frontend/lib/api/base/base-clients.ts b/frontend/lib/api/base/base-clients.ts index 265549cc74a4..d993216eb6d1 100644 --- a/frontend/lib/api/base/base-clients.ts +++ b/frontend/lib/api/base/base-clients.ts @@ -20,11 +20,10 @@ export abstract class BaseAPI { } } -export abstract class BaseCRUDAPI +export abstract class BaseCRUDAPIReadOnly extends BaseAPI - implements CrudAPIInterface -{ - abstract baseRoute: string; + implements CrudAPIInterface { + abstract baseRoute: (string); abstract itemRoute(itemId: string | number): string; async getAll(page = 1, perPage = -1, params = {} as Record) { @@ -32,13 +31,17 @@ export abstract class BaseCRUDAPI return await this.requests.get>(route(this.baseRoute, { page, perPage, ...params })); } - async createOne(payload: CreateType) { - return await this.requests.post(this.baseRoute, payload); - } - async getOne(itemId: string | number) { return await this.requests.get(this.itemRoute(itemId)); } +} + +export abstract class BaseCRUDAPI + extends BaseCRUDAPIReadOnly + implements CrudAPIInterface { + async createOne(payload: CreateType) { + return await this.requests.post(this.baseRoute, payload); + } async updateOne(itemId: string | number, payload: UpdateType) { return await this.requests.put(this.itemRoute(itemId), payload); diff --git a/frontend/lib/api/client-public.ts b/frontend/lib/api/client-public.ts index b613d616ffea..7745c3c54862 100644 --- a/frontend/lib/api/client-public.ts +++ b/frontend/lib/api/client-public.ts @@ -5,13 +5,20 @@ import { ApiRequestInstance } from "~/lib/api/types/non-generated"; export class PublicApi { public validators: ValidatorsApi; - public explore: ExploreApi; public shared: SharedApi; constructor(requests: ApiRequestInstance) { this.validators = new ValidatorsApi(requests); - this.explore = new ExploreApi(requests); this.shared = new SharedApi(requests); + } +} + +export class PublicExploreApi extends PublicApi { + public explore: ExploreApi; + + constructor(requests: ApiRequestInstance, groupSlug: string) { + super(requests); + this.explore = new ExploreApi(requests, groupSlug); Object.freeze(this); } diff --git a/frontend/lib/api/public/explore.ts b/frontend/lib/api/public/explore.ts index a750e42a16fa..51f7d71357e5 100644 --- a/frontend/lib/api/public/explore.ts +++ b/frontend/lib/api/public/explore.ts @@ -1,14 +1,25 @@ import { BaseAPI } from "../base/base-clients"; -import { Recipe } from "~/lib/api/types/recipe"; - -const prefix = "/api"; - -const routes = { - recipe: (groupSlug: string, recipeSlug: string) => `${prefix}/explore/recipes/${groupSlug}/${recipeSlug}`, -}; +import { ApiRequestInstance } from "~/lib/api/types/non-generated"; +import { PublicRecipeApi } from "./explore/recipes"; +import { PublicFoodsApi } from "./explore/foods"; +import { PublicCategoriesApi, PublicTagsApi, PublicToolsApi } from "./explore/organizers"; +import { PublicCookbooksApi } from "./explore/cookbooks"; export class ExploreApi extends BaseAPI { - async recipe(groupSlug: string, recipeSlug: string) { - return await this.requests.get(routes.recipe(groupSlug, recipeSlug)); + public recipes: PublicRecipeApi; + public cookbooks: PublicCookbooksApi; + public foods: PublicFoodsApi; + public categories: PublicCategoriesApi; + public tags: PublicTagsApi; + public tools: PublicToolsApi; + + constructor(requests: ApiRequestInstance, groupSlug: string) { + super(requests); + this.recipes = new PublicRecipeApi(requests, groupSlug); + this.cookbooks = new PublicCookbooksApi(requests, groupSlug); + this.foods = new PublicFoodsApi(requests, groupSlug); + this.categories = new PublicCategoriesApi(requests, groupSlug); + this.tags = new PublicTagsApi(requests, groupSlug); + this.tools = new PublicToolsApi(requests, groupSlug); } } diff --git a/frontend/lib/api/public/explore/cookbooks.ts b/frontend/lib/api/public/explore/cookbooks.ts new file mode 100644 index 000000000000..0cf184a1f14a --- /dev/null +++ b/frontend/lib/api/public/explore/cookbooks.ts @@ -0,0 +1,19 @@ +import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; +import { RecipeCookBook } from "~/lib/api/types/cookbook"; +import { ApiRequestInstance } from "~/lib/api/types/non-generated"; + +const prefix = "/api"; + +const routes = { + cookbooksGroupSlug: (groupSlug: string | number) => `${prefix}/explore/cookbooks/${groupSlug}`, + cookbooksGroupSlugCookbookId: (groupSlug: string | number, cookbookId: string | number) => `${prefix}/explore/cookbooks/${groupSlug}/${cookbookId}`, +}; + +export class PublicCookbooksApi extends BaseCRUDAPIReadOnly { + baseRoute = routes.cookbooksGroupSlug(this.groupSlug); + itemRoute = (itemId: string | number) => routes.cookbooksGroupSlugCookbookId(this.groupSlug, itemId); + + constructor(requests: ApiRequestInstance, private readonly groupSlug: string) { + super(requests); + } +} diff --git a/frontend/lib/api/public/explore/foods.ts b/frontend/lib/api/public/explore/foods.ts new file mode 100644 index 000000000000..b2ebbe8c7627 --- /dev/null +++ b/frontend/lib/api/public/explore/foods.ts @@ -0,0 +1,19 @@ +import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; +import { IngredientFood } from "~/lib/api/types/recipe"; +import { ApiRequestInstance } from "~/lib/api/types/non-generated"; + +const prefix = "/api"; + +const routes = { + foodsGroupSlug: (groupSlug: string | number) => `${prefix}/explore/foods/${groupSlug}`, + foodsGroupSlugFoodId: (groupSlug: string | number, foodId: string | number) => `${prefix}/explore/foods/${groupSlug}/${foodId}`, +}; + +export class PublicFoodsApi extends BaseCRUDAPIReadOnly { + baseRoute = routes.foodsGroupSlug(this.groupSlug); + itemRoute = (itemId: string | number) => routes.foodsGroupSlugFoodId(this.groupSlug, itemId); + + constructor(requests: ApiRequestInstance, private readonly groupSlug: string) { + super(requests); + } +} diff --git a/frontend/lib/api/public/explore/organizers.ts b/frontend/lib/api/public/explore/organizers.ts new file mode 100644 index 000000000000..26b4734f0665 --- /dev/null +++ b/frontend/lib/api/public/explore/organizers.ts @@ -0,0 +1,41 @@ +import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; +import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; +import { ApiRequestInstance } from "~/lib/api/types/non-generated"; + +const prefix = "/api"; + +const routes = { + categoriesGroupSlug: (groupSlug: string | number) => `${prefix}/explore/organizers/${groupSlug}/categories`, + categoriesGroupSlugCategoryId: (groupSlug: string | number, categoryId: string | number) => `${prefix}/explore/organizers/${groupSlug}/categories/${categoryId}`, + tagsGroupSlug: (groupSlug: string | number) => `${prefix}/explore/organizers/${groupSlug}/tags`, + tagsGroupSlugTagId: (groupSlug: string | number, tagId: string | number) => `${prefix}/explore/organizers/${groupSlug}/tags/${tagId}`, + toolsGroupSlug: (groupSlug: string | number) => `${prefix}/explore/organizers/${groupSlug}/tools`, + toolsGroupSlugToolId: (groupSlug: string | number, toolId: string | number) => `${prefix}/explore/organizers/${groupSlug}/tools/${toolId}`, +}; + +export class PublicCategoriesApi extends BaseCRUDAPIReadOnly { + baseRoute = routes.categoriesGroupSlug(this.groupSlug); + itemRoute = (itemId: string | number) => routes.categoriesGroupSlugCategoryId(this.groupSlug, itemId); + + constructor(requests: ApiRequestInstance, private readonly groupSlug: string) { + super(requests); + } +} + +export class PublicTagsApi extends BaseCRUDAPIReadOnly { + baseRoute = routes.tagsGroupSlug(this.groupSlug); + itemRoute = (itemId: string | number) => routes.tagsGroupSlugTagId(this.groupSlug, itemId); + + constructor(requests: ApiRequestInstance, private readonly groupSlug: string) { + super(requests); + } +} + +export class PublicToolsApi extends BaseCRUDAPIReadOnly { + baseRoute = routes.toolsGroupSlug(this.groupSlug); + itemRoute = (itemId: string | number) => routes.toolsGroupSlugToolId(this.groupSlug, itemId); + + constructor(requests: ApiRequestInstance, private readonly groupSlug: string) { + super(requests); + } +} diff --git a/frontend/lib/api/public/explore/recipes.ts b/frontend/lib/api/public/explore/recipes.ts new file mode 100644 index 000000000000..060ada6e03f2 --- /dev/null +++ b/frontend/lib/api/public/explore/recipes.ts @@ -0,0 +1,19 @@ +import { BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients"; +import { Recipe } from "~/lib/api/types/recipe"; +import { ApiRequestInstance } from "~/lib/api/types/non-generated"; + +const prefix = "/api"; + +const routes = { + recipesGroupSlug: (groupSlug: string | number) => `${prefix}/explore/recipes/${groupSlug}`, + recipesGroupSlugRecipeSlug: (groupSlug: string | number, recipeSlug: string | number) => `${prefix}/explore/recipes/${groupSlug}/${recipeSlug}`, +}; + +export class PublicRecipeApi extends BaseCRUDAPIReadOnly { + baseRoute = routes.recipesGroupSlug(this.groupSlug); + itemRoute = (itemId: string | number) => routes.recipesGroupSlugRecipeSlug(this.groupSlug, itemId); + + constructor(requests: ApiRequestInstance, private readonly groupSlug: string) { + super(requests); + } +} diff --git a/frontend/pages/cookbooks/_slug.vue b/frontend/pages/cookbooks/_slug.vue index ee32f9f5f512..9b768831bcd0 100644 --- a/frontend/pages/cookbooks/_slug.vue +++ b/frontend/pages/cookbooks/_slug.vue @@ -1,64 +1,12 @@ diff --git a/frontend/pages/explore/cookbooks/_groupSlug/_slug.vue b/frontend/pages/explore/cookbooks/_groupSlug/_slug.vue new file mode 100644 index 000000000000..35387761892c --- /dev/null +++ b/frontend/pages/explore/cookbooks/_groupSlug/_slug.vue @@ -0,0 +1,23 @@ + + + diff --git a/frontend/pages/explore/recipes/_groupSlug/_slug.vue b/frontend/pages/explore/recipes/_groupSlug/_recipeSlug.vue similarity index 75% rename from frontend/pages/explore/recipes/_groupSlug/_slug.vue rename to frontend/pages/explore/recipes/_groupSlug/_recipeSlug.vue index 70f197a9e073..492a3422fdde 100644 --- a/frontend/pages/explore/recipes/_groupSlug/_slug.vue +++ b/frontend/pages/explore/recipes/_groupSlug/_recipeSlug.vue @@ -1,7 +1,7 @@ @@ -9,24 +9,24 @@ diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 78bae22a4bdc..676355a0c15e 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -1,481 +1,40 @@ - - diff --git a/frontend/pages/user/profile/index.vue b/frontend/pages/user/profile/index.vue index 603b4df911a0..316114ff94ec 100644 --- a/frontend/pages/user/profile/index.vue +++ b/frontend/pages/user/profile/index.vue @@ -16,18 +16,29 @@ {{ $t('profile.get-invite-link') }} + + + {{ $globals.icons.shareVariant }} + + {{ $t('profile.get-public-link') }} + -
+

- {{ generatedLink }} + {{ generatedSignupLink }}

- {{ $t("general.close") }} + {{ $t("general.close") }} - +