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>
This commit is contained in:
Michael Genson 2023-09-14 09:01:24 -05:00 committed by GitHub
parent e28b830cd4
commit 2c5e5a8421
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2399 additions and 953 deletions

View File

@ -0,0 +1,74 @@
<template>
<v-container v-if="book" fluid>
<v-app-bar color="transparent" flat class="mt-n1 rounded">
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
</v-app-bar>
<v-card flat>
<v-card-text class="py-0">
{{ book.description }}
</v-card-text>
</v-card>
<v-container class="pa-0">
<RecipeCardSection
class="mb-5 mx-1"
:recipes="recipes"
:query="{ cookbook: slug }"
:group-slug="groupSlug"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
/>
</v-container>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, useRoute, ref, useContext, useMeta } from "@nuxtjs/composition-api";
import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbook } from "~/composables/use-group-cookbooks";
export default defineComponent({
components: { RecipeCardSection },
props: {
groupSlug: {
type: String,
default: undefined,
}
},
setup(props) {
const { $auth } = useContext();
const loggedIn = computed(() => $auth.loggedIn);
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(loggedIn.value ? null : props.groupSlug);
const route = useRoute();
const slug = route.value.params.slug;
const { getOne } = useCookbook(loggedIn.value ? null : props.groupSlug);
const tab = ref(null);
const book = getOne(slug);
useMeta(() => {
return {
title: book?.value?.name || "Cookbook",
};
});
return {
book,
slug,
tab,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
};
},
head: {}, // Must include for useMeta
});
</script>

View File

@ -4,7 +4,7 @@
<v-card
:class="{ 'on-hover': hover }"
:elevation="hover ? 12 : 2"
:to="route ? `/recipe/${slug}` : ''"
:to="route ? recipeRoute : ''"
:min-height="imageHeight + 75"
@click="$emit('click')"
>
@ -39,7 +39,10 @@
<RecipeRating class="pb-1" :value="rating" :name="name" :slug="slug" :small="true" />
<v-spacer></v-spacer>
<RecipeChips :truncate="true" :items="tags" :title="false" :limit="2" :small="true" url-prefix="tags" />
<!-- If we're not logged-in, no items display, so we hide this menu -->
<RecipeContextMenu
v-if="loggedIn"
color="grey darken-2"
:slug="slug"
:name="name"
@ -80,6 +83,10 @@ export default defineComponent({
type: String,
required: true,
},
groupSlug: {
type: String,
default: null,
},
slug: {
type: String,
required: true,
@ -115,14 +122,19 @@ export default defineComponent({
default: 200,
},
},
setup() {
setup(props) {
const { $auth } = useContext();
const loggedIn = computed(() => {
return $auth.loggedIn;
});
const recipeRoute = computed<string>(() => {
return loggedIn.value ? `/recipe/${props.slug}` : `/explore/recipes/${props.groupSlug}/${props.slug}`;
});
return {
loggedIn,
recipeRoute,
};
},
});

View File

@ -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')"
>
<v-img v-if="vertical" class="rounded-sm">
@ -40,7 +40,7 @@
<RecipeFavoriteBadge v-if="loggedIn" :slug="slug" show-always />
<v-rating
color="secondary"
class="ml-auto"
:class="loggedIn ? 'ml-auto' : 'ml-auto pb-2'"
background-color="secondary lighten-3"
dense
length="5"
@ -48,7 +48,11 @@
:value="rating"
></v-rating>
<v-spacer></v-spacer>
<!-- If we're not logged-in, no items display, so we hide this menu -->
<!-- We also add padding to the v-rating above to compensate -->
<RecipeContextMenu
v-if="loggedIn"
:slug="slug"
:menu-icon="$globals.icons.dotsHorizontal"
:name="name"
@ -92,6 +96,10 @@ export default defineComponent({
type: String,
required: true,
},
groupSlug: {
type: String,
default: null,
},
slug: {
type: String,
required: true,
@ -126,14 +134,19 @@ export default defineComponent({
default: false,
},
},
setup() {
setup(props) {
const { $auth } = useContext();
const loggedIn = computed(() => {
return $auth.loggedIn;
});
const recipeRoute = computed<string>(() => {
return loggedIn.value ? `/recipe/${props.slug}` : `/explore/recipes/${props.groupSlug}/${props.slug}`;
});
return {
loggedIn,
recipeRoute,
};
},
});

View File

@ -76,6 +76,7 @@
<RecipeCard
:name="recipe.name"
:description="recipe.description"
:group-slug="groupSlug"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
@ -99,6 +100,7 @@
<RecipeCardMobile
:name="recipe.name"
:description="recipe.description"
:group-slug="groupSlug"
:slug="recipe.slug"
:rating="recipe.rating"
:image="recipe.image"
@ -163,6 +165,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
groupSlug: {
type: String,
default: null,
},
recipes: {
type: Array as () => 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;

View File

@ -9,7 +9,7 @@
color="accent"
:small="small"
dark
:to="`/?${urlPrefix}=${category.id}`"
:to=" loggedIn ? `/?${urlPrefix}=${category.id}` : undefined"
>
{{ truncateText(category.name) }}
</v-chip>
@ -17,7 +17,7 @@
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/user";
export type UrlPrefixParam = "tags" | "categories" | "tools";
@ -54,6 +54,11 @@ export default defineComponent({
},
},
setup(props) {
const { $auth } = useContext();
const loggedIn = computed(() => {
return $auth.loggedIn
})
function truncateText(text: string, length = 20, clamp = "...") {
if (!props.truncate) return text;
const node = document.createElement("div");
@ -63,6 +68,7 @@ export default defineComponent({
}
return {
loggedIn,
truncateText,
};
},

View File

@ -170,7 +170,7 @@
</template>
<script lang="ts">
import { defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api";
import { computed, defineComponent, reactive, toRefs, useContext, useRouter, ref } from "@nuxtjs/composition-api";
import RecipeIngredientListItem from "./RecipeIngredientListItem.vue";
import RecipeDialogPrintPreferences from "./RecipeDialogPrintPreferences.vue";
import RecipeDialogShare from "./RecipeDialogShare.vue";
@ -200,6 +200,7 @@ export interface ContextMenuItem {
icon: string;
color: string | undefined;
event: string;
isPublic: boolean;
}
export default defineComponent({
@ -299,7 +300,10 @@ export default defineComponent({
pickerMenu: false,
});
const { i18n, $globals } = useContext();
const { $auth, i18n, $globals } = useContext();
const loggedIn = computed(() => {
return $auth.loggedIn;
});
// ===========================================================================
// Context Menu Setup
@ -310,60 +314,70 @@ export default defineComponent({
icon: $globals.icons.edit,
color: undefined,
event: "edit",
isPublic: false,
},
delete: {
title: i18n.tc("general.delete"),
icon: $globals.icons.delete,
color: "error",
event: "delete",
isPublic: false,
},
download: {
title: i18n.tc("general.download"),
icon: $globals.icons.download,
color: undefined,
event: "download",
isPublic: false,
},
duplicate: {
title: i18n.tc("general.duplicate"),
icon: $globals.icons.duplicate,
color: undefined,
event: "duplicate",
isPublic: false,
},
mealplanner: {
title: i18n.tc("recipe.add-to-plan"),
icon: $globals.icons.calendar,
color: undefined,
event: "mealplanner",
isPublic: false,
},
shoppingList: {
title: i18n.tc("recipe.add-to-list"),
icon: $globals.icons.cartCheck,
color: undefined,
event: "shoppingList",
isPublic: false,
},
print: {
title: i18n.tc("general.print"),
icon: $globals.icons.printer,
color: undefined,
event: "print",
isPublic: true,
},
printPreferences: {
title: i18n.tc("general.print-preferences"),
icon: $globals.icons.printerSettings,
color: undefined,
event: "printPreferences",
isPublic: true,
},
share: {
title: i18n.tc("general.share"),
icon: $globals.icons.shareVariant,
color: undefined,
event: "share",
isPublic: false,
},
publicUrl: {
title: i18n.tc("recipe.public-link"),
icon: $globals.icons.contentCopy,
color: undefined,
event: "publicUrl",
isPublic: true,
},
};
@ -371,7 +385,7 @@ export default defineComponent({
for (const [key, value] of Object.entries(props.useItems)) {
if (value) {
const item = defaultItems[key];
if (item) {
if (item && (item.isPublic || loggedIn.value)) {
state.menuItems.push(item);
}
}

View File

@ -0,0 +1,495 @@
<template>
<v-container fluid class="pa-0">
<div class="search-container py-8">
<form class="search-box pa-2" @submit.prevent="search">
<div class="d-flex justify-center my-2">
<v-text-field
ref="input"
v-model="state.search"
outlined
hide-details
clearable
color="primary"
:placeholder="$tc('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search"
@keyup.enter="hideKeyboard"
/>
</div>
<div class="search-row">
<!-- Category Filter -->
<SearchFilter
v-if="categories"
v-model="selectedCategories"
:require-all.sync="state.requireAllCategories"
:items="categories"
>
<v-icon left>
{{ $globals.icons.tags }}
</v-icon>
{{ $t("category.categories") }}
</SearchFilter>
<!-- Tag Filter -->
<SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags">
<v-icon left>
{{ $globals.icons.tags }}
</v-icon>
{{ $t("tag.tags") }}
</SearchFilter>
<!-- Tool Filter -->
<SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools">
<v-icon left>
{{ $globals.icons.tools }}
</v-icon>
{{ $t("tool.tools") }}
</SearchFilter>
<!-- Food Filter -->
<SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods">
<v-icon left>
{{ $globals.icons.foods }}
</v-icon>
{{ $t("general.foods") }}
</SearchFilter>
<!-- Sort Options -->
<v-menu offset-y nudge-bottom="3">
<template #activator="{ on, attrs }">
<v-btn class="ml-auto" small color="accent" v-bind="attrs" v-on="on">
<v-icon :left="!$vuetify.breakpoint.xsOnly">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
</v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : sortText }}
</v-btn>
</template>
<v-card>
<v-list>
<v-list-item @click="toggleOrderDirection()">
<v-icon left>
{{ $globals.icons.sort }}
</v-icon>
<v-list-item-title>
{{ state.orderDirection === "asc" ? "Sort Descending" : "Sort Ascending" }}
</v-list-item-title>
</v-list-item>
<v-list-item
v-for="v in sortable"
:key="v.name"
:input-value="state.orderBy === v.value"
@click="state.orderBy = v.value"
>
<v-icon left>
{{ v.icon }}
</v-icon>
<v-list-item-title>{{ v.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- Settings -->
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
<template #activator="{ on, attrs }">
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
<v-icon small>
{{ $globals.icons.cog }}
</v-icon>
</v-btn>
</template>
<v-card>
<v-card-text>
<v-switch v-model="state.auto" :label="$t('search.auto-search')" single-line></v-switch>
<v-btn block color="primary" @click="reset">
{{ $tc("general.reset") }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</div>
<div v-if="!state.auto" class="search-button-container">
<v-btn x-large color="primary" type="submit" block>
<v-icon left>
{{ $globals.icons.search }}
</v-icon>
{{ $tc("search.search") }}
</v-btn>
</div>
</form>
</div>
<v-divider></v-divider>
<v-container class="mt-6 px-md-6">
<RecipeCardSection
class="mt-n5"
:icon="$globals.icons.search"
:title="$tc('search.results')"
:group-slug="groupSlug"
:recipes="recipes"
:query="passedQuery"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
/>
</v-container>
</v-container>
</template>
<script lang="ts">
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref } from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import { usePublicCategoryStore } from "~/composables/store/use-category-store";
import { usePublicFoodStore } from "~/composables/store/use-food-store";
import { usePublicTagStore } from "~/composables/store/use-tag-store";
import { usePublicToolStore } from "~/composables/store/use-tool-store";
export default defineComponent({
components: { SearchFilter, RecipeCardSection },
props: {
groupSlug: {
type: String,
required: true,
},
},
setup(props) {
const router = useRouter();
const { $auth, $globals, i18n } = useContext();
const loggedIn = computed(() => {
return $auth.loggedIn;
});
const state = ref({
auto: true,
search: "",
orderBy: "created_at",
orderDirection: "desc" as "asc" | "desc",
// and/or
requireAllCategories: false,
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
});
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(loggedIn.value ? null : props.groupSlug);
const categories = loggedIn.value ? useCategoryStore() : usePublicCategoryStore(props.groupSlug);
const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
const foods = loggedIn.value ? useFoodStore() : usePublicFoodStore(props.groupSlug);
const selectedFoods = ref<IngredientFood[]>([]);
const tags = loggedIn.value ? useTagStore() : usePublicTagStore(props.groupSlug);
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
const tools = loggedIn.value ? useToolStore() : usePublicToolStore(props.groupSlug);
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
const passedQuery = ref<RecipeSearchQuery | null>(null);
function reset() {
state.value.search = "";
state.value.orderBy = "created_at";
state.value.orderDirection = "desc";
state.value.requireAllCategories = false;
state.value.requireAllTags = false;
state.value.requireAllTools = false;
state.value.requireAllFoods = false;
selectedCategories.value = [];
selectedFoods.value = [];
selectedTags.value = [];
selectedTools.value = [];
router.push({
query: {},
});
search();
}
function toggleOrderDirection() {
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
}
function toIDArray(array: { id: string }[]) {
return array.map((item) => item.id);
}
function hideKeyboard() {
input.value.blur()
}
const input: Ref<any> = ref(null);
async function search() {
await router.push({
query: {
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
// Only add the query param if it's or not default
...{
auto: state.value.auto ? undefined : "false",
search: state.value.search === "" ? undefined : state.value.search,
orderBy: state.value.orderBy === "createdAt" ? undefined : state.value.orderBy,
orderDirection: state.value.orderDirection === "desc" ? undefined : state.value.orderDirection,
requireAllCategories: state.value.requireAllCategories ? "true" : undefined,
requireAllTags: state.value.requireAllTags ? "true" : undefined,
requireAllTools: state.value.requireAllTools ? "true" : undefined,
requireAllFoods: state.value.requireAllFoods ? "true" : undefined,
},
},
});
passedQuery.value = {
search: state.value.search,
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories,
requireAllTags: state.value.requireAllTags,
requireAllTools: state.value.requireAllTools,
requireAllFoods: state.value.requireAllFoods,
orderBy: state.value.orderBy,
orderDirection: state.value.orderDirection,
_searchSeed: Date.now().toString()
};
}
function waitUntilAndExecute(
condition: () => boolean,
callback: () => void,
opts = { timeout: 2000, interval: 500 }
): Promise<void> {
return new Promise((resolve, reject) => {
const state = {
timeout: undefined as number | undefined,
interval: undefined as number | undefined,
};
const check = () => {
if (condition()) {
clearInterval(state.interval);
clearTimeout(state.timeout);
callback();
resolve();
}
};
// For some reason these were returning NodeJS.Timeout
state.interval = setInterval(check, opts.interval) as unknown as number;
state.timeout = setTimeout(() => {
clearInterval(state.interval);
reject(new Error("Timeout"));
}, opts.timeout) as unknown as number;
});
}
const sortText = computed(() => {
const sort = sortable.find((s) => s.value === state.value.orderBy);
if (!sort) return "";
return `${sort.name}`;
});
const sortable = [
{
icon: $globals.icons.orderAlphabeticalAscending,
name: i18n.tc("general.sort-alphabetically"),
value: "name",
},
{
icon: $globals.icons.newBox,
name: i18n.tc("general.created"),
value: "created_at",
},
{
icon: $globals.icons.chefHat,
name: i18n.tc("general.last-made"),
value: "last_made",
},
{
icon: $globals.icons.star,
name: i18n.tc("general.rating"),
value: "rating",
},
{
icon: $globals.icons.update,
name: i18n.tc("general.updated"),
value: "update_at",
},
{
icon: $globals.icons.diceMultiple,
name: i18n.tc("general.random"),
value: "random",
},
];
onMounted(() => {
// Hydrate Search
// wait for stores to be hydrated
// read query params
const query = router.currentRoute.query;
if (query.auto) {
state.value.auto = query.auto === "true";
}
if (query.search) {
state.value.search = query.search as string;
}
if (query.orderBy) {
state.value.orderBy = query.orderBy as string;
}
if (query.orderDirection) {
state.value.orderDirection = query.orderDirection as "asc" | "desc";
}
const promises: Promise<void>[] = [];
if (query.categories) {
promises.push(
waitUntilAndExecute(
() => categories.items.value.length > 0,
() => {
const result = categories.items.value.filter((item) =>
(query.categories as string[]).includes(item.id as string)
);
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
}
)
);
}
if (query.foods) {
promises.push(
waitUntilAndExecute(
() => {
if (foods.foods.value) {
return foods.foods.value.length > 0;
}
return false;
},
() => {
const result = foods.foods.value?.filter((item) => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? [];
}
)
);
}
if (query.tags) {
promises.push(
waitUntilAndExecute(
() => tags.items.value.length > 0,
() => {
const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
)
);
}
if (query.tools) {
promises.push(
waitUntilAndExecute(
() => tools.items.value.length > 0,
() => {
const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
}
)
);
}
Promise.allSettled(promises).then(() => {
search();
});
});
watchDebounced(
[
() => state.value.search,
() => state.value.requireAllCategories,
() => state.value.requireAllTags,
() => state.value.requireAllTools,
() => state.value.requireAllFoods,
() => state.value.orderBy,
() => state.value.orderDirection,
selectedCategories,
selectedFoods,
selectedTags,
selectedTools,
],
async () => {
if (state.value.auto) {
await search();
}
},
{
debounce: 500,
}
);
return {
sortText,
search,
reset,
state,
categories: categories.items as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.items as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.foods,
tools: tools.items as unknown as NoUndefinedField<RecipeTool>[],
sortable,
toggleOrderDirection,
hideKeyboard,
input,
selectedCategories,
selectedFoods,
selectedTags,
selectedTools,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
passedQuery,
};
},
head: {},
});
</script>
<style lang="css">
.search-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.search-container {
display: flex;
justify-content: center;
}
.search-box {
width: 950px;
}
.search-button-container {
margin: 3rem auto 0 auto;
max-width: 500px;
}
</style>

View File

@ -0,0 +1,216 @@
<template>
<v-app dark>
<TheSnackbar />
<AppSidebar
v-model="sidebar"
absolute
:top-link="topLinks"
:secondary-header="$t('sidebar.cookbooks')"
:secondary-header-link="loggedIn ? '/group/cookbooks' : undefined"
:secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLink : []"
>
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
<template #activator="{ on, attrs }">
<v-btn v-if="loggedIn" rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
<v-icon left large color="primary">
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t("general.create") }}
</v-btn>
</template>
<v-list dense class="my-0 py-0">
<template v-for="(item, index) in createLinks">
<v-divider v-if="item.insertDivider" :key="index" class="mx-2"></v-divider>
<v-list-item v-if="!item.restricted || loggedIn" :key="item.title" :to="item.to" exact>
<v-list-item-avatar>
<v-icon>
{{ item.icon }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.subtitle">
{{ item.subtitle }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-list>
</v-menu>
<template #bottom>
<v-list-item @click.stop="languageDialog = true">
<v-list-item-icon>
<v-icon>{{ $globals.icons.translate }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
<LanguageDialog v-model="languageDialog" />
</v-list-item-content>
</v-list-item>
<v-list-item @click="toggleDark">
<v-list-item-icon>
<v-icon>
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
</v-list-item-title>
</v-list-item>
</template>
</AppSidebar>
<AppHeader :menu="loggedIn">
<v-btn icon @click.stop="sidebar = !sidebar">
<v-icon> {{ $globals.icons.menu }}</v-icon>
</v-btn>
</AppHeader>
<v-main>
<v-scroll-x-transition>
<Nuxt />
</v-scroll-x-transition>
</v-main>
</v-app>
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import { SidebarLinks } from "~/types/application-types";
import LanguageDialog from "~/components/global/LanguageDialog.vue";
import TheSnackbar from "@/components/Layout/LayoutParts/TheSnackbar.vue";
import { useCookbooks, usePublicCookbooks } from "~/composables/use-group-cookbooks";
import { useToggleDarkMode } from "~/composables/use-utils";
export default defineComponent({
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
setup() {
const { $globals, $auth, $vuetify, i18n } = useContext();
const isAdmin = computed(() => $auth.user?.admin);
const loggedIn = computed(() => $auth.loggedIn);
const route = useRoute();
const groupSlug = route.value.params.groupSlug;
const { cookbooks } = loggedIn.value ? useCookbooks() : usePublicCookbooks(groupSlug);
const toggleDark = useToggleDarkMode();
const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean | null>(null);
onMounted(() => {
sidebar.value = !$vuetify.breakpoint.md;
});
const cookbookLinks = computed(() => {
if (!cookbooks.value) return [];
return cookbooks.value.map((cookbook) => {
return {
icon: $globals.icons.pages,
title: cookbook.name,
to: loggedIn.value ? `/cookbooks/${cookbook.slug as string}` : `/explore/cookbooks/${groupSlug}/${cookbook.slug as string}`,
};
});
});
interface Link {
insertDivider: boolean;
icon: string;
title: string;
subtitle: string | null;
to: string;
restricted: boolean;
}
const createLinks: Link[] = [
{
insertDivider: false,
icon: $globals.icons.link,
title: i18n.tc("general.import"),
subtitle: i18n.tc("new-recipe.import-by-url"),
to: "/recipe/create/url",
restricted: true,
},
{
insertDivider: true,
icon: $globals.icons.edit,
title: i18n.tc("general.create"),
subtitle: i18n.tc("new-recipe.create-manually"),
to: "/recipe/create/new",
restricted: true,
},
{
insertDivider: true,
icon: $globals.icons.pages,
title: i18n.tc("sidebar.cookbook"),
subtitle: i18n.tc("sidebar.create-cookbook"),
to: "/group/cookbooks",
restricted: true,
},
];
const bottomLinks: SidebarLinks = [
{
icon: $globals.icons.cog,
title: i18n.tc("general.settings"),
to: "/admin/site-settings",
restricted: true,
},
];
const topLinks: SidebarLinks = [
{
icon: $globals.icons.search,
to: "/",
title: i18n.tc("sidebar.search"),
restricted: true,
},
{
icon: $globals.icons.calendarMultiselect,
title: i18n.tc("meal-plan.meal-planner"),
to: "/group/mealplan/planner",
restricted: true,
},
{
icon: $globals.icons.formatListCheck,
title: i18n.tc("shopping-list.shopping-lists"),
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.timelineText,
title: i18n.tc("recipe.timeline"),
to: "/group/timeline",
restricted: true,
},
{
icon: $globals.icons.tags,
to: "/recipes/categories",
title: i18n.tc("sidebar.categories"),
restricted: true,
},
{
icon: $globals.icons.tags,
to: "/recipes/tags",
title: i18n.tc("sidebar.tags"),
restricted: true,
},
{
icon: $globals.icons.potSteam,
to: "/recipes/tools",
title: i18n.tc("tool.tools"),
restricted: true,
},
];
return { cookbookLinks, createLinks, bottomLink: bottomLinks, topLinks, isAdmin, loggedIn, languageDialog, toggleDark, sidebar };
},
});
</script>

View File

@ -12,7 +12,7 @@
</v-row>
</v-footer>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
@ -22,4 +22,3 @@ export default defineComponent({
},
});
</script>

View File

@ -1,14 +1,14 @@
<template>
<v-app-bar clipped-left dense app color="primary" dark class="d-print-none">
<slot />
<router-link to="/">
<router-link :to="routerLink">
<v-btn icon>
<v-icon size="40"> {{ $globals.icons.primary }} </v-icon>
</v-btn>
</router-link>
<div btn class="pl-2">
<v-toolbar-title style="cursor: pointer" @click="$router.push('/')"> Mealie </v-toolbar-title>
<v-toolbar-title style="cursor: pointer" @click="$router.push(routerLink)"> Mealie </v-toolbar-title>
</div>
<RecipeDialogSearch ref="domSearchDialog" />
@ -48,7 +48,7 @@
</template>
<script lang="ts">
import { defineComponent, onBeforeUnmount, onMounted, ref } from "@nuxtjs/composition-api";
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
export default defineComponent({
@ -60,6 +60,15 @@ export default defineComponent({
},
},
setup() {
const { $auth } = useContext();
const route = useRoute();
const loggedIn = computed(() => {
return $auth.loggedIn;
});
const groupSlug = route.value.params.groupSlug;
const routerLink = !loggedIn.value && groupSlug ? `/explore/recipes/${groupSlug}` : "/"
const domSearchDialog = ref<InstanceType<typeof RecipeDialogSearch> | null>(null);
function activateSearch() {
@ -85,6 +94,7 @@ export default defineComponent({
return {
activateSearch,
domSearchDialog,
routerLink,
};
},
});

View File

@ -26,41 +26,43 @@
<template v-if="topLink">
<v-list nav dense>
<template v-for="nav in topLink">
<!-- Multi Items -->
<v-list-group
v-if="nav.children && ($auth.loggedIn || !nav.restricted)"
:key="nav.title + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
>
<template #activator>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>
<div v-if="!nav.restricted || loggedIn" :key="nav.title">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="nav.title + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
>
<template #activator>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>
<v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
<v-divider class="mb-4"></v-divider>
</v-list-group>
<v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
<v-divider class="mb-4"></v-divider>
</v-list-group>
<!-- Single Item -->
<v-list-item-group
v-else-if="$auth.loggedIn || !nav.restricted"
:key="nav.title + 'single-item'"
v-model="secondarySelected"
color="primary"
>
<v-list-item exact link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
<!-- Single Item -->
<v-list-item-group
v-else
:key="nav.title + 'single-item'"
v-model="secondarySelected"
color="primary"
>
<v-list-item exact link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
</div>
</template>
</v-list>
</template>
@ -73,36 +75,38 @@
<v-divider></v-divider>
<v-list nav dense exact>
<template v-for="nav in secondaryLinks">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="nav.title + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
>
<template #activator>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>
<div v-if="!nav.restricted || loggedIn" :key="nav.title">
<!-- Multi Items -->
<v-list-group
v-if="nav.children"
:key="nav.title + 'multi-item'"
v-model="dropDowns[nav.title]"
color="primary"
:prepend-icon="nav.icon"
>
<template #activator>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</template>
<v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
<v-divider class="mb-4"></v-divider>
</v-list-group>
<v-list-item v-for="child in nav.children" :key="child.title" exact :to="child.to">
<v-list-item-icon>
<v-icon>{{ child.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ child.title }}</v-list-item-title>
</v-list-item>
<v-divider class="mb-4"></v-divider>
</v-list-group>
<!-- Single Item -->
<v-list-item-group v-else :key="nav.title + 'single-item'" v-model="secondarySelected" color="primary">
<v-list-item exact link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
<!-- Single Item -->
<v-list-item-group v-else :key="nav.title + 'single-item'" v-model="secondarySelected" color="primary">
<v-list-item exact link :to="nav.to">
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</v-list-item-group>
</div>
</template>
</v-list>
</template>
@ -112,20 +116,21 @@
<v-list nav dense>
<v-list-item-group v-model="bottomSelected" color="primary">
<template v-for="nav in bottomLinks">
<v-list-item
v-if="!nav.restricted || $auth.loggedIn"
:key="nav.title"
exact
link
:to="nav.to || null"
:href="nav.href || null"
:target="nav.href ? '_blank' : null"
>
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
<div v-if="!nav.restricted || loggedIn" :key="nav.title">
<v-list-item
:key="nav.title"
exact
link
:to="nav.to || null"
:href="nav.href || null"
:target="nav.href ? '_blank' : null"
>
<v-list-item-icon>
<v-icon>{{ nav.icon }}</v-icon>
</v-list-item-icon>
<v-list-item-title>{{ nav.title }}</v-list-item-title>
</v-list-item>
</div>
</template>
</v-list-item-group>
<slot name="bottom"></slot>
@ -135,7 +140,7 @@
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs } from "@nuxtjs/composition-api";
import { computed, defineComponent, reactive, toRefs, useContext } from "@nuxtjs/composition-api";
import { SidebarLinks } from "~/types/application-types";
import UserAvatar from "~/components/Domain/User/UserAvatar.vue";
@ -192,6 +197,9 @@ export default defineComponent({
},
});
const { $auth } = useContext();
const loggedIn = computed(() => $auth.loggedIn);
const state = reactive({
dropDowns: {},
topSelected: null as string[] | null,
@ -203,6 +211,7 @@ export default defineComponent({
return {
...toRefs(state),
drawer,
loggedIn,
};
},
});

View File

@ -3,6 +3,7 @@ import { useContext } from "@nuxtjs/composition-api";
import type { NuxtAxiosInstance } from "@nuxtjs/axios";
import { ApiRequestInstance, RequestResponse } from "~/lib/api/types/non-generated";
import { AdminAPI, PublicApi, UserApi } from "~/lib/api";
import { PublicExploreApi } from "~/lib/api/client-public";
const request = {
async safe<T, U>(
@ -80,3 +81,11 @@ export const usePublicApi = function (): PublicApi {
const requests = getRequests($axios);
return new PublicApi(requests);
};
export const usePublicExploreApi = function (groupSlug: string): PublicExploreApi {
const { $axios, i18n } = useContext();
$axios.setHeader("Accept-Language", i18n.locale);
const requests = getRequests($axios);
return new PublicExploreApi(requests, groupSlug);
}

View File

@ -1,20 +1,75 @@
import { Ref, useAsync } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { BaseCRUDAPI } from "~/lib/api/base/base-clients";
import { BaseCRUDAPI, BaseCRUDAPIReadOnly } from "~/lib/api/base/base-clients";
import { QueryValue } from "~/lib/api/base/route";
type BoundT = {
id?: string | number;
};
interface StoreActions<T extends BoundT> {
interface PublicStoreActions<T extends BoundT> {
getAll(page?: number, perPage?: number, params?: any): Ref<T[] | null>;
refresh(): Promise<void>;
}
interface StoreActions<T extends BoundT> extends PublicStoreActions<T> {
createOne(createData: T): Promise<void>;
updateOne(updateData: T): Promise<void>;
deleteOne(id: string | number): Promise<void>;
}
/**
* usePublicStoreActions is a factory function that returns a set of methods
* that can be reused to manage the state of a data store without using
* Vuex. This is primarily used for basic GET/GETALL operations that required
* a lot of refreshing hooks to be called on operations
*/
export function usePublicStoreActions<T extends BoundT>(
api: BaseCRUDAPIReadOnly<T>,
allRef: Ref<T[] | null> | null,
loading: Ref<boolean>
): PublicStoreActions<T> {
function getAll(page = 1, perPage = -1, params = {} as Record<string, QueryValue>) {
params.orderBy ??= "name";
params.orderDirection ??= "asc";
loading.value = true;
const allItems = useAsync(async () => {
const { data } = await api.getAll(page, perPage, params);
if (data && allRef) {
allRef.value = data.items;
}
if (data) {
return data.items ?? [];
} else {
return [];
}
}, useAsyncKey());
loading.value = false;
return allItems;
}
async function refresh() {
loading.value = true;
const { data } = await api.getAll();
if (data && data.items && allRef) {
allRef.value = data.items;
}
loading.value = false;
}
return {
getAll,
refresh,
};
}
/**
* useStoreActions is a factory function that returns a set of methods
* that can be reused to manage the state of a data store without using

View File

@ -1,5 +1,6 @@
import { useAsync, ref } from "@nuxtjs/composition-api";
import { useAsyncKey } from "../use-utils";
import { usePublicExploreApi } from "~/composables/api/api-client";
import { useUserApi } from "~/composables/api";
import { Recipe } from "~/lib/api/types/recipe";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
@ -7,8 +8,9 @@ import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
export const allRecipes = ref<Recipe[]>([]);
export const recentRecipes = ref<Recipe[]>([]);
export const useLazyRecipes = function () {
const api = useUserApi();
export const useLazyRecipes = function (publicGroupSlug: string | null = null) {
// passing the group slug switches to using the public API
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
const recipes = ref<Recipe[]>([]);
@ -20,6 +22,7 @@ export const useLazyRecipes = function () {
query: RecipeSearchQuery | null = null,
queryFilter: string | null = null,
) {
const { data } = await api.recipes.getAll(page, perPage, {
orderBy,
orderDirection,
@ -73,8 +76,12 @@ export const useLazyRecipes = function () {
};
};
export const useRecipes = (all = false, fetchRecipes = true, loadFood = false) => {
const api = useUserApi();
export const useRecipes = (
all = false, fetchRecipes = true,
loadFood = false,
publicGroupSlug: string | null = null
) => {
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
// recipes is non-reactive!!
const { recipes, page, perPage } = (() => {

View File

@ -1,5 +1,6 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { RecipeCategory } from "~/lib/api/types/admin";
@ -24,7 +25,30 @@ export function useCategoryData() {
};
}
export function usePublicCategoryStore(groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = ref(false);
const actions = {
...usePublicStoreActions<RecipeCategory>(api.categories, categoryStore, loading),
flushStore() {
categoryStore.value = [];
},
};
if (!categoryStore.value || categoryStore.value?.length === 0) {
actions.getAll();
}
return {
items: categoryStore,
actions,
loading,
};
}
export function useCategoryStore() {
// passing the group slug switches to using the public API
const api = useUserApi();
const loading = ref(false);

View File

@ -1,5 +1,6 @@
import { ref, reactive, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { IngredientFood } from "~/lib/api/types/recipe";
@ -31,6 +32,24 @@ export const useFoodData = function () {
};
};
export const usePublicFoodStore = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = ref(false);
const actions = {
...usePublicStoreActions(api.foods, foodStore, loading),
flushStore() {
foodStore = null;
},
};
if (!foodStore) {
foodStore = actions.getAll();
}
return { foods: foodStore, actions };
};
export const useFoodStore = function () {
const api = useUserApi();
const loading = ref(false);

View File

@ -1,5 +1,6 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { useUserApi } from "~/composables/api";
import { RecipeTag } from "~/lib/api/types/admin";
@ -24,6 +25,28 @@ export function useTagData() {
};
}
export function usePublicTagStore(groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = ref(false);
const actions = {
...usePublicStoreActions<RecipeTag>(api.tags, items, loading),
flushStore() {
items.value = [];
},
};
if (!items.value || items.value?.length === 0) {
actions.getAll();
}
return {
items,
actions,
loading,
};
}
export function useTagStore() {
const api = useUserApi();
const loading = ref(false);

View File

@ -1,5 +1,6 @@
import { reactive, ref, Ref } from "@nuxtjs/composition-api";
import { useStoreActions } from "../partials/use-actions-factory";
import { usePublicExploreApi } from "../api/api-client";
import { usePublicStoreActions, useStoreActions } from "../partials/use-actions-factory";
import { useUserApi } from "~/composables/api";
import { RecipeTool } from "~/lib/api/types/recipe";
@ -26,6 +27,28 @@ export function useToolData() {
};
}
export function usePublicToolStore(groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = ref(false);
const actions = {
...usePublicStoreActions<RecipeTool>(api.tools, toolStore, loading),
flushStore() {
toolStore.value = [];
},
};
if (!toolStore.value || toolStore.value?.length === 0) {
actions.getAll();
}
return {
items: toolStore,
actions,
loading,
};
}
export function useToolStore() {
const api = useUserApi();
const loading = ref(false);

View File

@ -1,13 +1,15 @@
import { useAsync, ref, Ref, useContext } from "@nuxtjs/composition-api";
import { useAsyncKey } from "./use-utils";
import { usePublicExploreApi } from "./api/api-client";
import { useUserApi } from "~/composables/api";
import { ReadCookBook, UpdateCookBook } from "~/lib/api/types/cookbook";
let cookbookStore: Ref<ReadCookBook[] | null> | null = null;
export const useCookbook = function () {
export const useCookbook = function (publicGroupSlug: string | null = null) {
function getOne(id: string | number) {
const api = useUserApi();
// passing the group slug switches to using the public API
const api = publicGroupSlug ? usePublicExploreApi(publicGroupSlug).explore : useUserApi();
const units = useAsync(async () => {
const { data } = await api.cookbooks.getOne(id);
@ -21,6 +23,48 @@ export const useCookbook = function () {
return { getOne };
};
export const usePublicCookbooks = function (groupSlug: string) {
const api = usePublicExploreApi(groupSlug).explore;
const loading = ref(false);
const actions = {
getAll() {
loading.value = true;
const units = useAsync(async () => {
const { data } = await api.cookbooks.getAll(1, -1, { orderBy: "position", orderDirection: "asc" });
if (data) {
return data.items;
} else {
return null;
}
}, useAsyncKey());
loading.value = false;
return units;
},
async refreshAll() {
loading.value = true;
const { data } = await api.cookbooks.getAll(1, -1, { orderBy: "position", orderDirection: "asc" });
if (data && data.items && cookbookStore) {
cookbookStore.value = data.items;
}
loading.value = false;
},
flushStore() {
cookbookStore = null;
},
};
if (!cookbookStore) {
cookbookStore = actions.getAll();
}
return { cookbooks: cookbookStore, actions };
}
export const useCookbooks = function () {
const api = useUserApi();
const loading = ref(false);

View File

@ -1070,6 +1070,7 @@
"welcome-user": "👋 Welcome, {0}",
"description": "Manage your profile, recipes, and group settings.",
"get-invite-link": "Get Invite Link",
"get-public-link": "Get Public Link",
"account-summary": "Account Summary",
"account-summary-description": "Here's a summary of your group's information",
"group-statistics": "Group Statistics",
@ -1122,4 +1123,4 @@
"cookbook-name": "Cookbook Name",
"cookbook-with-name": "Cookbook {0}"
}
}
}

View File

@ -27,9 +27,9 @@
<script lang="ts">
import { defineComponent, ref, useContext, onMounted } from "@nuxtjs/composition-api";
import AppHeader from "@/components/Layout/AppHeader.vue";
import AppSidebar from "@/components/Layout/AppSidebar.vue";
import TheSnackbar from "~/components/Layout/TheSnackbar.vue";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
import AppSidebar from "@/components/Layout/LayoutParts/AppSidebar.vue";
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import { SidebarLinks } from "~/types/application-types";
export default defineComponent({
@ -48,28 +48,33 @@ export default defineComponent({
{
icon: $globals.icons.cog,
to: "/admin/site-settings",
title: i18n.t("sidebar.site-settings"),
title: i18n.tc("sidebar.site-settings"),
restricted: true,
},
// {
// icon: $globals.icons.chart,
// to: "/admin/analytics",
// title: "Analytics",
// restricted: true,
// },
{
icon: $globals.icons.user,
to: "/admin/manage/users",
title: i18n.t("user.users"),
title: i18n.tc("user.users"),
restricted: true,
},
{
icon: $globals.icons.group,
to: "/admin/manage/groups",
title: i18n.t("group.groups"),
title: i18n.tc("group.groups"),
restricted: true,
},
{
icon: $globals.icons.database,
to: "/admin/backups",
title: i18n.t("sidebar.backups"),
title: i18n.tc("sidebar.backups"),
restricted: true,
},
];
@ -77,25 +82,29 @@ export default defineComponent({
{
icon: $globals.icons.wrench,
to: "/admin/maintenance",
title: i18n.t("sidebar.maintenance"),
title: i18n.tc("sidebar.maintenance"),
restricted: true,
},
{
icon: $globals.icons.check,
to: "/admin/background-tasks",
title: i18n.t("sidebar.background-tasks"),
title: i18n.tc("sidebar.background-tasks"),
restricted: true,
},
{
icon: $globals.icons.slotMachine,
to: "/admin/parser",
title: i18n.t("sidebar.parser"),
title: i18n.tc("sidebar.parser"),
restricted: true,
},
];
const bottomLinks: SidebarLinks = [
{
icon: $globals.icons.heart,
title: i18n.t("about.support"),
title: i18n.tc("about.support"),
href: "https://github.com/sponsors/hay-kot",
restricted: true,
},
];

View File

@ -10,13 +10,13 @@
</v-main>
</v-app>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import TheSnackbar from "~/components/Layout/TheSnackbar.vue";
import AppHeader from "@/components/Layout/AppHeader.vue";
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import AppHeader from "@/components/Layout/LayoutParts/AppHeader.vue";
export default defineComponent({
components: { TheSnackbar, AppHeader },
});
</script>
</script>

View File

@ -20,7 +20,7 @@
<script lang="ts">
import { computed, defineComponent, useContext } from "@nuxtjs/composition-api";
import TheSnackbar from "~/components/Layout/TheSnackbar.vue";
import TheSnackbar from "~/components/Layout/LayoutParts/TheSnackbar.vue";
import { useAppInfo } from "~/composables/api";
export default defineComponent({
components: { TheSnackbar },

View File

@ -1,198 +1,14 @@
<template>
<v-app dark>
<TheSnackbar />
<AppSidebar
v-model="sidebar"
absolute
:top-link="topLinks"
:secondary-header="$t('sidebar.cookbooks')"
secondary-header-link="/group/cookbooks"
:secondary-links="cookbookLinks || []"
:bottom-links="isAdmin ? bottomLink : []"
>
<v-menu offset-y nudge-bottom="5" close-delay="50" nudge-right="15">
<template #activator="{ on, attrs }">
<v-btn rounded large class="ml-2 mt-3" v-bind="attrs" v-on="on">
<v-icon left large color="primary">
{{ $globals.icons.createAlt }}
</v-icon>
{{ $t("general.create") }}
</v-btn>
</template>
<v-list dense class="my-0 py-0">
<template v-for="(item, index) in createLinks">
<v-divider v-if="item.divider" :key="index" class="mx-2"></v-divider>
<v-list-item v-else :key="item.title" :to="item.to" exact>
<v-list-item-avatar>
<v-icon>
{{ item.icon }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle>
{{ item.subtitle }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-list>
</v-menu>
<template #bottom>
<v-list-item @click.stop="languageDialog = true">
<v-list-item-icon>
<v-icon>{{ $globals.icons.translate }}</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title>
<LanguageDialog v-model="languageDialog" />
</v-list-item-content>
</v-list-item>
<v-list-item @click="toggleDark">
<v-list-item-icon>
<v-icon>
{{ $vuetify.theme.dark ? $globals.icons.weatherSunny : $globals.icons.weatherNight }}
</v-icon>
</v-list-item-icon>
<v-list-item-title>
{{ $vuetify.theme.dark ? $t("settings.theme.light-mode") : $t("settings.theme.dark-mode") }}
</v-list-item-title>
</v-list-item>
</template>
</AppSidebar>
<AppHeader>
<v-btn icon @click.stop="sidebar = !sidebar">
<v-icon> {{ $globals.icons.menu }}</v-icon>
</v-btn>
</AppHeader>
<v-main>
<v-scroll-x-transition>
<Nuxt />
</v-scroll-x-transition>
</v-main>
</v-app>
<DefaultLayout />
</template>
<script lang="ts">
import { computed, defineComponent, onMounted, ref, useContext } from "@nuxtjs/composition-api";
import AppHeader from "@/components/Layout/AppHeader.vue";
import AppSidebar from "@/components/Layout/AppSidebar.vue";
import LanguageDialog from "~/components/global/LanguageDialog.vue";
import TheSnackbar from "@/components/Layout/TheSnackbar.vue";
import { useCookbooks } from "~/composables/use-group-cookbooks";
import { useToggleDarkMode } from "~/composables/use-utils";
import { defineComponent } from "@nuxtjs/composition-api";
import DefaultLayout from "@/components/Layout/DefaultLayout.vue";
export default defineComponent({
components: { AppHeader, AppSidebar, LanguageDialog, TheSnackbar },
components: { DefaultLayout },
middleware: "auth",
setup() {
const { cookbooks } = useCookbooks();
const { $globals, $auth, $vuetify, i18n } = useContext();
const isAdmin = computed(() => $auth.user?.admin);
const toggleDark = useToggleDarkMode();
const languageDialog = ref<boolean>(false);
const sidebar = ref<boolean | null>(null);
onMounted(() => {
sidebar.value = !$vuetify.breakpoint.md;
});
const cookbookLinks = computed(() => {
if (!cookbooks.value) return [];
return cookbooks.value.map((cookbook) => {
return {
icon: $globals.icons.pages,
title: cookbook.name,
to: `/cookbooks/${cookbook.slug as string}`,
};
});
});
const createLinks = [
{
icon: $globals.icons.link,
title: i18n.t("general.import"),
subtitle: i18n.t("new-recipe.import-by-url"),
to: "/recipe/create/url",
restricted: true,
},
{ divider: true },
{
icon: $globals.icons.edit,
title: i18n.t("general.create"),
subtitle: i18n.t("new-recipe.create-manually"),
to: "/recipe/create/new",
restricted: true,
},
{ divider: true },
{
icon: $globals.icons.pages,
title: i18n.t("sidebar.cookbook"),
subtitle: i18n.t("sidebar.create-cookbook"),
to: "/group/cookbooks",
restricted: true,
},
];
const bottomLink = [
{
icon: $globals.icons.cog,
title: i18n.t("general.settings"),
to: "/admin/site-settings",
restricted: true,
},
];
const topLinks = [
{
icon: $globals.icons.search,
to: "/",
title: i18n.t("sidebar.search"),
},
{
icon: $globals.icons.calendarMultiselect,
title: i18n.t("meal-plan.meal-planner"),
to: "/group/mealplan/planner",
restricted: true,
},
{
icon: $globals.icons.formatListCheck,
title: i18n.t("shopping-list.shopping-lists"),
to: "/shopping-lists",
restricted: true,
},
{
icon: $globals.icons.timelineText,
title: i18n.t("recipe.timeline"),
to: "/group/timeline",
restricted: true,
},
{
icon: $globals.icons.tags,
to: "/recipes/categories",
title: i18n.t("sidebar.categories"),
},
{
icon: $globals.icons.tags,
to: "/recipes/tags",
title: i18n.t("sidebar.tags"),
},
{
icon: $globals.icons.potSteam,
to: "/recipes/tools",
title: i18n.t("tool.tools"),
},
];
return { cookbookLinks, createLinks, bottomLink, topLinks, isAdmin, languageDialog, toggleDark, sidebar };
},
});
</script>

View File

@ -0,0 +1,13 @@
<template>
<DefaultLayout />
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
import DefaultLayout from "@/components/Layout/DefaultLayout.vue";
export default defineComponent({
components: { DefaultLayout },
});
</script>

View File

@ -20,11 +20,10 @@ export abstract class BaseAPI {
}
}
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
export abstract class BaseCRUDAPIReadOnly<ReadType>
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<string, QueryValue>) {
@ -32,13 +31,17 @@ export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
return await this.requests.get<PaginationData<ReadType>>(route(this.baseRoute, { page, perPage, ...params }));
}
async createOne(payload: CreateType) {
return await this.requests.post<ReadType>(this.baseRoute, payload);
}
async getOne(itemId: string | number) {
return await this.requests.get<ReadType>(this.itemRoute(itemId));
}
}
export abstract class BaseCRUDAPI<CreateType, ReadType, UpdateType = CreateType>
extends BaseCRUDAPIReadOnly<ReadType>
implements CrudAPIInterface {
async createOne(payload: CreateType) {
return await this.requests.post<ReadType>(this.baseRoute, payload);
}
async updateOne(itemId: string | number, payload: UpdateType) {
return await this.requests.put<ReadType, UpdateType>(this.itemRoute(itemId), payload);

View File

@ -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);
}

View File

@ -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<Recipe>(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);
}
}

View File

@ -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<RecipeCookBook> {
baseRoute = routes.cookbooksGroupSlug(this.groupSlug);
itemRoute = (itemId: string | number) => routes.cookbooksGroupSlugCookbookId(this.groupSlug, itemId);
constructor(requests: ApiRequestInstance, private readonly groupSlug: string) {
super(requests);
}
}

View File

@ -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<IngredientFood> {
baseRoute = routes.foodsGroupSlug(this.groupSlug);
itemRoute = (itemId: string | number) => routes.foodsGroupSlugFoodId(this.groupSlug, itemId);
constructor(requests: ApiRequestInstance, private readonly groupSlug: string) {
super(requests);
}
}

View File

@ -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<RecipeCategory> {
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<RecipeTag> {
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<RecipeTool> {
baseRoute = routes.toolsGroupSlug(this.groupSlug);
itemRoute = (itemId: string | number) => routes.toolsGroupSlugToolId(this.groupSlug, itemId);
constructor(requests: ApiRequestInstance, private readonly groupSlug: string) {
super(requests);
}
}

View File

@ -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<Recipe> {
baseRoute = routes.recipesGroupSlug(this.groupSlug);
itemRoute = (itemId: string | number) => routes.recipesGroupSlugRecipeSlug(this.groupSlug, itemId);
constructor(requests: ApiRequestInstance, private readonly groupSlug: string) {
super(requests);
}
}

View File

@ -1,64 +1,12 @@
<template>
<v-container v-if="book" fluid>
<v-app-bar color="transparent" flat class="mt-n1 rounded">
<v-icon large left> {{ $globals.icons.pages }} </v-icon>
<v-toolbar-title class="headline"> {{ book.name }} </v-toolbar-title>
</v-app-bar>
<v-card flat>
<v-card-text class="py-0">
{{ book.description }}
</v-card-text>
</v-card>
<v-container class="pa-0">
<RecipeCardSection
class="mb-5 mx-1"
:recipes="recipes"
:query="{ cookbook: slug }"
@sortRecipes="assignSorted"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
@delete="removeRecipe"
/>
</v-container>
</v-container>
<CookbookPage />
</template>
<script lang="ts">
import { defineComponent, useRoute, ref, useMeta } from "@nuxtjs/composition-api";
import { useLazyRecipes } from "~/composables/recipes";
import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue";
import { useCookbook } from "~/composables/use-group-cookbooks";
import { defineComponent } from "@nuxtjs/composition-api";
import CookbookPage from "@/components/Domain/Cookbook/CookbookPage.vue";
export default defineComponent({
components: { RecipeCardSection },
setup() {
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
const route = useRoute();
const slug = route.value.params.slug;
const { getOne } = useCookbook();
const tab = ref(null);
const book = getOne(slug);
useMeta(() => {
return {
title: book?.value?.name || "Cookbook",
};
});
return {
book,
slug,
tab,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
};
},
head: {}, // Must include for useMeta
});
components: { CookbookPage },
})
</script>

View File

@ -0,0 +1,23 @@
<template>
<client-only>
<CookbookPage :group-slug="groupSlug" />
</client-only>
</template>
<script lang="ts">
import { defineComponent, useRoute } from "@nuxtjs/composition-api";
import CookbookPage from "@/components/Domain/Cookbook/CookbookPage.vue";
export default defineComponent({
components: { CookbookPage },
layout: "explore",
setup() {
const route = useRoute();
const groupSlug = route.value.params.groupSlug;
return {
groupSlug,
}
},
})
</script>

View File

@ -1,7 +1,7 @@
<template>
<div>
<div v-if="recipe">
<client-only>
<RecipePage v-if="recipe" :recipe="recipe" />
<RecipePage :recipe="recipe" />
</client-only>
</div>
</template>
@ -9,24 +9,24 @@
<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 { usePublicExploreApi } from "~/composables/api/api-client";
import { useRecipeMeta } from "~/composables/recipes";
export default defineComponent({
components: { RecipePage },
layout: "basic",
layout: "explore",
setup() {
const route = useRoute();
const router = useRouter();
const groupSlug = route.value.params.groupSlug;
const slug = route.value.params.slug;
const api = usePublicApi();
const recipeSlug = route.value.params.recipeSlug;
const api = usePublicExploreApi(groupSlug);
const { meta, title } = useMeta();
const { recipeMeta } = useRecipeMeta();
const recipe = useAsync(async () => {
const { data, error } = await api.explore.recipe(groupSlug, slug);
const { data, error } = await api.explore.recipes.getOne(recipeSlug);
if (error) {
console.error("error loading recipe -> ", error);

View File

@ -0,0 +1,42 @@
<template>
<div v-if="groupSlug">
<client-only>
<RecipeExplorerPage :group-slug="groupSlug" />
</client-only>
</div>
</template>
<script lang="ts">
import { defineComponent, useRoute, useRouter } from "@nuxtjs/composition-api";
import { invoke } from "@vueuse/core";
import RecipeExplorerPage from "~/components/Domain/Recipe/RecipeExplorerPage.vue";
import { usePublicExploreApi } from "~/composables/api/api-client";
export default defineComponent({
components: { RecipeExplorerPage },
layout: "explore",
setup() {
const route = useRoute();
const router = useRouter();
const groupSlug = route.value.params.groupSlug;
const api = usePublicExploreApi(groupSlug);
invoke(async () => {
if (!groupSlug) {
return;
}
// try to fetch one tag to make sure the group slug is valid
const { data } = await api.explore.tags.getAll(1, 1);
if (!data) {
// the group slug is invalid, so leave the page (this results in a 404)
router.push("/explore/recipes");
}
});
return {
groupSlug,
};
},
});
</script>

View File

@ -1,481 +1,40 @@
<template>
<v-container fluid class="pa-0">
<div class="search-container py-8">
<form class="search-box pa-2" @submit.prevent="search">
<div class="d-flex justify-center my-2">
<v-text-field
ref="input"
v-model="state.search"
outlined
hide-details
clearable
color="primary"
:placeholder="$tc('search.search-placeholder')"
:prepend-inner-icon="$globals.icons.search"
@keyup.enter="hideKeyboard"
/>
</div>
<div class="search-row">
<!-- Category Filter -->
<SearchFilter
v-if="categories"
v-model="selectedCategories"
:require-all.sync="state.requireAllCategories"
:items="categories"
>
<v-icon left>
{{ $globals.icons.tags }}
</v-icon>
{{ $t("category.categories") }}
</SearchFilter>
<!-- Tag Filter -->
<SearchFilter v-if="tags" v-model="selectedTags" :require-all.sync="state.requireAllTags" :items="tags">
<v-icon left>
{{ $globals.icons.tags }}
</v-icon>
{{ $t("tag.tags") }}
</SearchFilter>
<!-- Tool Filter -->
<SearchFilter v-if="tools" v-model="selectedTools" :require-all.sync="state.requireAllTools" :items="tools">
<v-icon left>
{{ $globals.icons.tools }}
</v-icon>
{{ $t("tool.tools") }}
</SearchFilter>
<!-- Food Filter -->
<SearchFilter v-if="foods" v-model="selectedFoods" :require-all.sync="state.requireAllFoods" :items="foods">
<v-icon left>
{{ $globals.icons.foods }}
</v-icon>
{{ $t("general.foods") }}
</SearchFilter>
<!-- Sort Options -->
<v-menu offset-y nudge-bottom="3">
<template #activator="{ on, attrs }">
<v-btn class="ml-auto" small color="accent" v-bind="attrs" v-on="on">
<v-icon :left="!$vuetify.breakpoint.xsOnly">
{{ state.orderDirection === "asc" ? $globals.icons.sortAscending : $globals.icons.sortDescending }}
</v-icon>
{{ $vuetify.breakpoint.xsOnly ? null : sortText }}
</v-btn>
</template>
<v-card>
<v-list>
<v-list-item @click="toggleOrderDirection()">
<v-icon left>
{{ $globals.icons.sort }}
</v-icon>
<v-list-item-title>
{{ state.orderDirection === "asc" ? "Sort Descending" : "Sort Ascending" }}
</v-list-item-title>
</v-list-item>
<v-list-item
v-for="v in sortable"
:key="v.name"
:input-value="state.orderBy === v.value"
@click="state.orderBy = v.value"
>
<v-icon left>
{{ v.icon }}
</v-icon>
<v-list-item-title>{{ v.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<!-- Settings -->
<v-menu offset-y bottom left nudge-bottom="3" :close-on-content-click="false">
<template #activator="{ on, attrs }">
<v-btn small color="accent" dark v-bind="attrs" v-on="on">
<v-icon small>
{{ $globals.icons.cog }}
</v-icon>
</v-btn>
</template>
<v-card>
<v-card-text>
<v-switch v-model="state.auto" :label="$t('search.auto-search')" single-line></v-switch>
<v-btn block color="primary" @click="reset">
{{ $tc("general.reset") }}
</v-btn>
</v-card-text>
</v-card>
</v-menu>
</div>
<div v-if="!state.auto" class="search-button-container">
<v-btn x-large color="primary" type="submit" block>
<v-icon left>
{{ $globals.icons.search }}
</v-icon>
{{ $tc("search.search") }}
</v-btn>
</div>
</form>
</div>
<v-divider></v-divider>
<v-container class="mt-6 px-md-6">
<RecipeCardSection
class="mt-n5"
:icon="$globals.icons.search"
:title="$tc('search.results')"
:recipes="recipes"
:query="passedQuery"
@replaceRecipes="replaceRecipes"
@appendRecipes="appendRecipes"
/>
</v-container>
</v-container>
<div v-if="groupSlug">
<RecipeExplorerPage :group-slug="groupSlug" />
</div>
</template>
<script lang="ts">
import { ref, defineComponent, useRouter, onMounted, useContext, computed, Ref } from "@nuxtjs/composition-api";
import { watchDebounced } from "@vueuse/shared";
import SearchFilter from "~/components/Domain/SearchFilter.vue";
import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store";
import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue";
import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { NoUndefinedField } from "~/lib/api/types/non-generated";
import { useLazyRecipes } from "~/composables/recipes";
import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe";
import { defineComponent, ref, useContext } from "@nuxtjs/composition-api";
import { invoke } from "@vueuse/core";
import { useUserApi } from "~/composables/api/api-client";
import RecipeExplorerPage from "~/components/Domain/Recipe/RecipeExplorerPage.vue";
export default defineComponent({
components: { SearchFilter, RecipeCardSection },
components: { RecipeExplorerPage },
setup() {
const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes();
const { $auth } = useContext();
const api = useUserApi();
const router = useRouter();
const { $globals, i18n } = useContext();
// @ts-ignore $auth.user is typed as unknown, even though it's a user
const groupId: string | undefined = $auth.user?.groupId;
const groupSlug = ref<string>();
const state = ref({
auto: true,
search: "",
orderBy: "created_at",
orderDirection: "desc" as "asc" | "desc",
// and/or
requireAllCategories: false,
requireAllTags: false,
requireAllTools: false,
requireAllFoods: false,
invoke(async () => {
if (!groupId) {
return;
}
const { data } = await api.groups.getOne(groupId);
if (data) {
groupSlug.value = data.slug;
}
});
const categories = useCategoryStore();
const selectedCategories = ref<NoUndefinedField<RecipeCategory>[]>([]);
const foods = useFoodStore();
const selectedFoods = ref<IngredientFood[]>([]);
const tags = useTagStore();
const selectedTags = ref<NoUndefinedField<RecipeTag>[]>([]);
const tools = useToolStore();
const selectedTools = ref<NoUndefinedField<RecipeTool>[]>([]);
const passedQuery = ref<RecipeSearchQuery | null>(null);
function reset() {
state.value.search = "";
state.value.orderBy = "created_at";
state.value.orderDirection = "desc";
state.value.requireAllCategories = false;
state.value.requireAllTags = false;
state.value.requireAllTools = false;
state.value.requireAllFoods = false;
selectedCategories.value = [];
selectedFoods.value = [];
selectedTags.value = [];
selectedTools.value = [];
router.push({
query: {},
});
search();
}
function toggleOrderDirection() {
state.value.orderDirection = state.value.orderDirection === "asc" ? "desc" : "asc";
}
function toIDArray(array: { id: string }[]) {
return array.map((item) => item.id);
}
function hideKeyboard() {
input.value.blur()
}
const input: Ref<any> = ref(null);
async function search() {
await router.push({
query: {
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
// Only add the query param if it's or not default
...{
auto: state.value.auto ? undefined : "false",
search: state.value.search === "" ? undefined : state.value.search,
orderBy: state.value.orderBy === "createdAt" ? undefined : state.value.orderBy,
orderDirection: state.value.orderDirection === "desc" ? undefined : state.value.orderDirection,
requireAllCategories: state.value.requireAllCategories ? "true" : undefined,
requireAllTags: state.value.requireAllTags ? "true" : undefined,
requireAllTools: state.value.requireAllTools ? "true" : undefined,
requireAllFoods: state.value.requireAllFoods ? "true" : undefined,
},
},
});
passedQuery.value = {
search: state.value.search,
categories: toIDArray(selectedCategories.value),
foods: toIDArray(selectedFoods.value),
tags: toIDArray(selectedTags.value),
tools: toIDArray(selectedTools.value),
requireAllCategories: state.value.requireAllCategories,
requireAllTags: state.value.requireAllTags,
requireAllTools: state.value.requireAllTools,
requireAllFoods: state.value.requireAllFoods,
orderBy: state.value.orderBy,
orderDirection: state.value.orderDirection,
_searchSeed: Date.now().toString()
};
}
function waitUntilAndExecute(
condition: () => boolean,
callback: () => void,
opts = { timeout: 2000, interval: 500 }
): Promise<void> {
return new Promise((resolve, reject) => {
const state = {
timeout: undefined as number | undefined,
interval: undefined as number | undefined,
};
const check = () => {
if (condition()) {
clearInterval(state.interval);
clearTimeout(state.timeout);
callback();
resolve();
}
};
// For some reason these were returning NodeJS.Timeout
state.interval = setInterval(check, opts.interval) as unknown as number;
state.timeout = setTimeout(() => {
clearInterval(state.interval);
reject(new Error("Timeout"));
}, opts.timeout) as unknown as number;
});
}
const sortText = computed(() => {
const sort = sortable.find((s) => s.value === state.value.orderBy);
if (!sort) return "";
return `${sort.name}`;
});
const sortable = [
{
icon: $globals.icons.orderAlphabeticalAscending,
name: i18n.tc("general.sort-alphabetically"),
value: "name",
},
{
icon: $globals.icons.newBox,
name: i18n.tc("general.created"),
value: "created_at",
},
{
icon: $globals.icons.chefHat,
name: i18n.tc("general.last-made"),
value: "last_made",
},
{
icon: $globals.icons.star,
name: i18n.tc("general.rating"),
value: "rating",
},
{
icon: $globals.icons.update,
name: i18n.tc("general.updated"),
value: "update_at",
},
{
icon: $globals.icons.diceMultiple,
name: i18n.tc("general.random"),
value: "random",
},
];
onMounted(() => {
// Hydrate Search
// wait for stores to be hydrated
// read query params
const query = router.currentRoute.query;
if (query.auto) {
state.value.auto = query.auto === "true";
}
if (query.search) {
state.value.search = query.search as string;
}
if (query.orderBy) {
state.value.orderBy = query.orderBy as string;
}
if (query.orderDirection) {
state.value.orderDirection = query.orderDirection as "asc" | "desc";
}
const promises: Promise<void>[] = [];
if (query.categories) {
promises.push(
waitUntilAndExecute(
() => categories.items.value.length > 0,
() => {
const result = categories.items.value.filter((item) =>
(query.categories as string[]).includes(item.id as string)
);
selectedCategories.value = result as NoUndefinedField<RecipeCategory>[];
}
)
);
}
if (query.foods) {
promises.push(
waitUntilAndExecute(
() => {
if (foods.foods.value) {
return foods.foods.value.length > 0;
}
return false;
},
() => {
const result = foods.foods.value?.filter((item) => (query.foods as string[]).includes(item.id));
selectedFoods.value = result ?? [];
}
)
);
}
if (query.tags) {
promises.push(
waitUntilAndExecute(
() => tags.items.value.length > 0,
() => {
const result = tags.items.value.filter((item) => (query.tags as string[]).includes(item.id as string));
selectedTags.value = result as NoUndefinedField<RecipeTag>[];
}
)
);
}
if (query.tools) {
promises.push(
waitUntilAndExecute(
() => tools.items.value.length > 0,
() => {
const result = tools.items.value.filter((item) => (query.tools as string[]).includes(item.id));
selectedTools.value = result as NoUndefinedField<RecipeTool>[];
}
)
);
}
Promise.allSettled(promises).then(() => {
search();
});
});
watchDebounced(
[
() => state.value.search,
() => state.value.requireAllCategories,
() => state.value.requireAllTags,
() => state.value.requireAllTools,
() => state.value.requireAllFoods,
() => state.value.orderBy,
() => state.value.orderDirection,
selectedCategories,
selectedFoods,
selectedTags,
selectedTools,
],
async () => {
if (state.value.auto) {
await search();
}
},
{
debounce: 500,
}
);
return {
sortText,
search,
reset,
state,
categories: categories.items as unknown as NoUndefinedField<RecipeCategory>[],
tags: tags.items as unknown as NoUndefinedField<RecipeTag>[],
foods: foods.foods,
tools: tools.items as unknown as NoUndefinedField<RecipeTool>[],
sortable,
toggleOrderDirection,
hideKeyboard,
input,
selectedCategories,
selectedFoods,
selectedTags,
selectedTools,
appendRecipes,
assignSorted,
recipes,
removeRecipe,
replaceRecipes,
passedQuery,
groupSlug,
};
},
});
</script>
<style lang="css">
.search-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.search-container {
display: flex;
justify-content: center;
}
.search-box {
width: 950px;
}
.search-button-container {
margin: 3rem auto 0 auto;
max-width: 500px;
}
</style>

View File

@ -16,18 +16,29 @@
</v-icon>
{{ $t('profile.get-invite-link') }}
</v-btn>
<v-btn
v-if="group && group.preferences && !group.preferences.privateGroup"
outlined
rounded
@click="getPublicLink()"
>
<v-icon left>
{{ $globals.icons.shareVariant }}
</v-icon>
{{ $t('profile.get-public-link') }}
</v-btn>
</v-card-actions>
<div v-show="generatedLink !== ''">
<div v-show="generatedSignupLink !== ''">
<v-card-text>
<p class="text-center pb-0">
{{ generatedLink }}
{{ generatedSignupLink }}
</p>
<v-text-field v-model="sendTo" :label="$t('user.email')" :rules="[validators.email]"> </v-text-field>
</v-card-text>
<v-card-actions class="py-0 align-center" style="gap: 4px">
<BaseButton cancel @click="generatedLink = ''"> {{ $t("general.close") }} </BaseButton>
<BaseButton cancel @click="generatedSignupLink = ''"> {{ $t("general.close") }} </BaseButton>
<v-spacer></v-spacer>
<AppButtonCopy :icon="false" color="info" :copy-text="generatedLink" />
<AppButtonCopy :icon="false" color="info" :copy-text="generatedSignupLink" />
<BaseButton color="info" :disabled="!validEmail" :loading="loading" @click="sendInvite">
<template #icon>
{{ $globals.icons.email }}
@ -36,6 +47,18 @@
</BaseButton>
</v-card-actions>
</div>
<div v-show="showPublicLink">
<v-card-text>
<p class="text-center pb-0">
{{ publicLink }}
</p>
</v-card-text>
<v-card-actions class="py-0 align-center" style="gap: 4px">
<BaseButton cancel @click="showPublicLink = false"> {{ $t("general.close") }} </BaseButton>
<v-spacer></v-spacer>
<AppButtonCopy :icon="false" color="info" :copy-text="publicLink" />
</v-card-actions>
</div>
</v-card>
</section>
<section class="my-3">
@ -196,6 +219,7 @@
<script lang="ts">
import { computed, defineComponent, useContext, ref, toRefs, reactive, useAsync } from "@nuxtjs/composition-api";
import { invoke, until } from "@vueuse/core";
import UserProfileLinkCard from "@/components/Domain/User/UserProfileLinkCard.vue";
import { useUserApi } from "~/composables/api";
import { validators } from "~/composables/use-validators";
@ -203,6 +227,7 @@ import { alert } from "~/composables/use-toast";
import UserAvatar from "@/components/Domain/User/UserAvatar.vue";
import { useAsyncKey } from "~/composables/use-utils";
import StatsCards from "~/components/global/StatsCards.vue";
import { GroupInDB, UserOut } from "~/lib/api/types/user";
export default defineComponent({
name: "UserProfile",
@ -215,16 +240,41 @@ export default defineComponent({
setup() {
const { $auth, i18n } = useContext();
const user = computed(() => $auth.user);
// @ts-ignore $auth.user is typed as unknown, but it's a user
const user = computed<UserOut | null>(() => $auth.user);
const group = ref<GroupInDB | null>(null);
const generatedLink = ref("");
const showPublicLink = ref(false);
const publicLink = ref("");
const generatedSignupLink = ref("");
const token = ref("");
const api = useUserApi();
invoke(async () => {
await until(user.value).not.toBeNull();
if (!user.value) {
return;
}
const { data } = await api.groups.getOne(user.value.groupId);
group.value = data;
});
function getPublicLink() {
if (group.value) {
publicLink.value = `${window.location.origin}/explore/recipes/${group.value.slug}`
showPublicLink.value = true;
generatedSignupLink.value = "";
}
}
async function getSignupLink() {
const { data } = await api.groups.createInvitation({ uses: 1 });
if (data) {
token.value = data.token;
generatedLink.value = constructLink(data.token);
generatedSignupLink.value = constructLink(data.token);
showPublicLink.value = false;
}
}
@ -341,11 +391,15 @@ export default defineComponent({
getStatsTitle,
getStatsIcon,
getStatsTo,
group,
stats,
user,
constructLink,
generatedLink,
generatedSignupLink,
showPublicLink,
publicLink,
getSignupLink,
getPublicLink,
sendInvite,
validators,
validEmail,

View File

@ -4,8 +4,9 @@ export interface SideBarLink {
icon: string;
to?: string;
href?: string;
title: TranslateResult;
title: string;
children?: SideBarLink[];
restricted: boolean;
}
export type SidebarLinks = Array<SideBarLink>;

View File

@ -32,10 +32,7 @@ import ReportTable from "@/components/global/ReportTable.vue";
import SafeMarkdown from "@/components/global/SafeMarkdown.vue";
import StatsCards from "@/components/global/StatsCards.vue";
import ToggleState from "@/components/global/ToggleState.vue";
import AppFooter from "@/components/layout/AppFooter.vue";
import AppHeader from "@/components/layout/AppHeader.vue";
import AppSidebar from "@/components/layout/AppSidebar.vue";
import TheSnackbar from "@/components/layout/TheSnackbar.vue";
import DefaultLayout from "@/components/layout/DefaultLayout.vue";
declare module "vue" {
export interface GlobalComponents {
@ -74,10 +71,7 @@ declare module "vue" {
StatsCards: typeof StatsCards;
ToggleState: typeof ToggleState;
// Layout Components
AppFooter: typeof AppFooter;
AppHeader: typeof AppHeader;
AppSidebar: typeof AppSidebar;
TheSnackbar: typeof TheSnackbar;
DefaultLayout: typeof DefaultLayout;
}
}

View File

@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator, Callable, Generator
from pathlib import Path
from uuid import uuid4
import fastapi
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
@ -13,7 +14,7 @@ from mealie.core.config import get_app_dirs, get_app_settings
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user import PrivateUser, TokenData
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID
from mealie.schema.user.user import DEFAULT_INTEGRATION_ID, GroupInDB
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_error=False)
@ -53,6 +54,16 @@ async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail), session=De
return False
async def get_public_group(group_slug: str = fastapi.Path(...), session=Depends(generate_session)) -> GroupInDB:
repos = get_repositories(session)
group = repos.groups.get_by_slug_or_id(group_slug)
if not group or group.preferences.private_group:
raise HTTPException(404, "group not found")
else:
return group
async def get_current_user(token: str = Depends(oauth2_scheme), session=Depends(generate_session)) -> PrivateUser:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View File

@ -6,7 +6,7 @@ from pydantic import UUID4
from sqlalchemy.orm import Session
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.core.dependencies.dependencies import get_admin_user, get_current_user, get_integration_id
from mealie.core.dependencies.dependencies import get_admin_user, get_current_user, get_integration_id, get_public_group
from mealie.core.exceptions import mealie_registered_exceptions
from mealie.core.root_logger import get_logger
from mealie.core.settings.directories import AppDirectories
@ -72,6 +72,16 @@ class BasePublicController(_BaseController):
...
class BasePublicExploreController(BasePublicController):
"""
This is a public class for all User restricted controllers in the API.
It includes the common SharedDependencies and some common methods used
by all Admin controllers.
"""
group: GroupInDB = Depends(get_public_group)
class BaseUserController(_BaseController):
"""
This is a base class for all User restricted controllers in the API.

View File

@ -1,7 +1,19 @@
from fastapi import APIRouter
from . import controller_public_recipes
from . import (
controller_public_cookbooks,
controller_public_foods,
controller_public_organizers,
controller_public_recipes,
)
prefix = "/explore"
router = APIRouter()
router.include_router(controller_public_recipes.router)
router.include_router(controller_public_cookbooks.router, prefix=prefix, tags=["Explore: Cookbooks"])
router.include_router(controller_public_foods.router, prefix=prefix, tags=["Explore: Foods"])
router.include_router(controller_public_organizers.categories_router, prefix=prefix, tags=["Explore: Categories"])
router.include_router(controller_public_organizers.tags_router, prefix=prefix, tags=["Explore: Tags"])
router.include_router(controller_public_organizers.tools_router, prefix=prefix, tags=["Explore: Tools"])
router.include_router(controller_public_recipes.router, prefix=prefix, tags=["Explore: Recipes"])

View File

@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.schema.cookbook.cookbook import ReadCookBook, RecipeCookBook
from mealie.schema.make_dependable import make_dependable
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = APIRouter(prefix="/cookbooks/{group_slug}")
@controller(router)
class PublicCookbooksController(BasePublicExploreController):
@property
def cookbooks(self):
return self.repos.cookbooks.by_group(self.group.id)
@property
def recipes(self):
return self.repos.recipes.by_group(self.group.id)
@router.get("", response_model=PaginationBase[ReadCookBook])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[ReadCookBook]:
public_filter = "public = TRUE"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
response = self.cookbooks.page_all(
pagination=q,
override=ReadCookBook,
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@router.get("/{item_id}", response_model=RecipeCookBook)
def get_one(self, item_id: UUID4 | str) -> RecipeCookBook:
match_attr = "slug" if isinstance(item_id, str) else "id"
cookbook = self.cookbooks.get_one(item_id, match_attr)
if not cookbook or not cookbook.public:
raise HTTPException(404, "cookbook not found")
recipes = self.recipes.page_all(
PaginationQuery(page=1, per_page=-1, query_filter="settings.public = TRUE"), cookbook=cookbook
)
return cookbook.cast(RecipeCookBook, recipes=recipes.items)

View File

@ -0,0 +1,38 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe.recipe_ingredient import IngredientFood
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
router = APIRouter(prefix="/foods/{group_slug}")
@controller(router)
class PublicFoodsController(BasePublicExploreController):
@property
def ingredient_foods(self):
return self.repos.ingredient_foods.by_group(self.group.id)
@router.get("", response_model=PaginationBase[IngredientFood])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[IngredientFood]:
response = self.ingredient_foods.page_all(
pagination=q,
override=IngredientFood,
search=search,
)
response.set_pagination_guides(router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@router.get("/{item_id}", response_model=IngredientFood)
def get_one(self, item_id: UUID4) -> IngredientFood:
item = self.ingredient_foods.get_one(item_id)
if not item:
raise HTTPException(404, "food not found")
return item

View File

@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe.recipe import RecipeCategory, RecipeTag, RecipeTool
from mealie.schema.recipe.recipe_category import CategoryOut, TagOut
from mealie.schema.recipe.recipe_tool import RecipeToolOut
from mealie.schema.response.pagination import PaginationBase, PaginationQuery
base_prefix = "/organizers/{group_slug}"
categories_router = APIRouter(prefix=f"{base_prefix}/categories")
tags_router = APIRouter(prefix=f"{base_prefix}/tags")
tools_router = APIRouter(prefix=f"{base_prefix}/tools")
@controller(categories_router)
class PublicCategoriesController(BasePublicExploreController):
@property
def categories(self):
return self.repos.categories.by_group(self.group.id)
@categories_router.get("", response_model=PaginationBase[RecipeCategory])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[RecipeCategory]:
response = self.categories.page_all(
pagination=q,
override=RecipeCategory,
search=search,
)
response.set_pagination_guides(categories_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@categories_router.get("/{item_id}", response_model=CategoryOut)
def get_one(self, item_id: UUID4) -> CategoryOut:
item = self.categories.get_one(item_id)
if not item:
raise HTTPException(404, "category not found")
return item
@controller(tags_router)
class PublicTagsController(BasePublicExploreController):
@property
def tags(self):
return self.repos.tags.by_group(self.group.id)
@tags_router.get("", response_model=PaginationBase[RecipeTag])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[RecipeTag]:
response = self.tags.page_all(
pagination=q,
override=RecipeTag,
search=search,
)
response.set_pagination_guides(tags_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@tags_router.get("/{item_id}", response_model=TagOut)
def get_one(self, item_id: UUID4) -> TagOut:
item = self.tags.get_one(item_id)
if not item:
raise HTTPException(404, "tag not found")
return item
@controller(tools_router)
class PublicToolsController(BasePublicExploreController):
@property
def tools(self):
return self.repos.tools.by_group(self.group.id)
@tools_router.get("", response_model=PaginationBase[RecipeTool])
def get_all(
self, q: PaginationQuery = Depends(make_dependable(PaginationQuery)), search: str | None = None
) -> PaginationBase[RecipeTool]:
response = self.tools.page_all(
pagination=q,
override=RecipeTool,
search=search,
)
response.set_pagination_guides(tools_router.url_path_for("get_all", group_slug=self.group.slug), q.dict())
return response
@tools_router.get("/{item_id}", response_model=RecipeToolOut)
def get_one(self, item_id: UUID4) -> RecipeToolOut:
item = self.tools.get_one(item_id)
if not item:
raise HTTPException(404, "tool not found")
return item

View File

@ -1,22 +1,83 @@
from fastapi import APIRouter, HTTPException
import orjson
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import UUID4
from mealie.routes._base import controller
from mealie.routes._base.base_controllers import BasePublicController
from mealie.routes._base.base_controllers import BasePublicExploreController
from mealie.routes.recipe.recipe_crud_routes import JSONBytes
from mealie.schema.cookbook.cookbook import ReadCookBook
from mealie.schema.make_dependable import make_dependable
from mealie.schema.recipe import Recipe
from mealie.schema.recipe.recipe import RecipeSummary
from mealie.schema.response.pagination import PaginationBase, PaginationQuery, RecipeSearchQuery
router = APIRouter(prefix="/explore", tags=["Explore: Recipes"])
router = APIRouter(prefix="/recipes/{group_slug}")
@controller(router)
class PublicRecipesController(BasePublicController):
@router.get("/recipes/{group_slug}/{recipe_slug}", response_model=Recipe)
def get_recipe(self, group_slug: str, recipe_slug: str) -> Recipe:
group = self.repos.groups.get_by_slug_or_id(group_slug)
class PublicRecipesController(BasePublicExploreController):
@property
def cookbooks(self):
return self.repos.cookbooks.by_group(self.group.id)
if not group or group.preferences.private_group:
raise HTTPException(404, "group not found")
@property
def recipes(self):
return self.repos.recipes.by_group(self.group.id)
recipe = self.repos.recipes.by_group(group.id).get_one(recipe_slug)
@router.get("", response_model=PaginationBase[RecipeSummary])
def get_all(
self,
request: Request,
q: PaginationQuery = Depends(make_dependable(PaginationQuery)),
search_query: RecipeSearchQuery = Depends(make_dependable(RecipeSearchQuery)),
categories: list[UUID4 | str] | None = Query(None),
tags: list[UUID4 | str] | None = Query(None),
tools: list[UUID4 | str] | None = Query(None),
foods: list[UUID4 | str] | None = Query(None),
) -> PaginationBase[RecipeSummary]:
cookbook_data: ReadCookBook | None = None
if search_query.cookbook:
cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id"
cookbook_data = self.cookbooks.get_one(search_query.cookbook, cb_match_attr)
if cookbook_data is None or not cookbook_data.public:
raise HTTPException(status_code=404, detail="cookbook not found")
public_filter = "settings.public = TRUE"
if q.query_filter:
q.query_filter = f"({q.query_filter}) AND {public_filter}"
else:
q.query_filter = public_filter
pagination_response = self.recipes.page_all(
pagination=q,
cookbook=cookbook_data,
categories=categories,
tags=tags,
tools=tools,
foods=foods,
require_all_categories=search_query.require_all_categories,
require_all_tags=search_query.require_all_tags,
require_all_tools=search_query.require_all_tools,
require_all_foods=search_query.require_all_foods,
search=search_query.search,
)
# merge default pagination with the request's query params
query_params = q.dict() | {**request.query_params}
pagination_response.set_pagination_guides(
router.url_path_for("get_all", group_slug=self.group.slug),
{k: v for k, v in query_params.items() if v is not None},
)
json_compatible_response = orjson.dumps(pagination_response.dict(by_alias=True))
# Response is returned directly, to avoid validation and improve performance
return JSONBytes(content=json_compatible_response)
@router.get("/{recipe_slug}", response_model=Recipe)
def get_recipe(self, recipe_slug: str) -> Recipe:
recipe = self.repos.recipes.by_group(self.group.id).get_one(recipe_slug)
if not recipe or not recipe.settings.public:
raise HTTPException(404, "recipe not found")

View File

@ -250,7 +250,7 @@ class RecipeController(BaseRecipeController):
cb_match_attr = "slug" if isinstance(search_query.cookbook, str) else "id"
cookbook_data = self.cookbooks_repo.get_one(search_query.cookbook, cb_match_attr)
if search_query.cookbook is None:
if cookbook_data is None:
raise HTTPException(status_code=404, detail="cookbook not found")
pagination_response = self.repo.page_all(

View File

@ -1,62 +0,0 @@
from dataclasses import dataclass
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe
from tests.utils import api_routes
from tests.utils.fixture_schemas import TestUser
@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)
assert group
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
recipe_group = database.groups.get_by_slug_or_id(random_recipe.group_id)
response = api_client.get(
api_routes.explore_recipes_group_slug_recipe_slug(
recipe_group.slug,
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

View File

@ -0,0 +1,151 @@
import random
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.cookbook.cookbook import SaveCookBook
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.recipe.recipe_category import TagSave
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_all_cookbooks(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Cookbooks
default_cookbooks = database.cookbooks.create_many(
[SaveCookBook(name=random_string(), group_id=unique_user.group_id) for _ in range(random_int(15, 20))]
)
random.shuffle(default_cookbooks)
split_index = random_int(6, 12)
public_cookbooks = default_cookbooks[:split_index]
private_cookbooks = default_cookbooks[split_index:]
for cookbook in public_cookbooks:
cookbook.public = True
for cookbook in private_cookbooks:
cookbook.public = False
database.cookbooks.update_many(public_cookbooks + private_cookbooks)
## Test Cookbooks
response = api_client.get(api_routes.explore_cookbooks_group_slug(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
cookbooks_data = response.json()
fetched_ids: set[str] = {cookbook["id"] for cookbook in cookbooks_data["items"]}
for cookbook in public_cookbooks:
assert str(cookbook.id) in fetched_ids
for cookbook in private_cookbooks:
assert str(cookbook.id) not in fetched_ids
@pytest.mark.parametrize(
"is_private_group, is_private_cookbook",
[
(True, True),
(True, False),
(False, True),
(False, False),
],
ids=[
"group_is_private_cookbook_is_private",
"group_is_private_cookbook_is_public",
"group_is_public_cookbook_is_private",
"group_is_public_cookbook_is_public",
],
)
def test_get_one_cookbook(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
is_private_cookbook: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Cookbook
cookbook = database.cookbooks.create(
SaveCookBook(
name=random_string(),
group_id=unique_user.group_id,
public=not is_private_cookbook,
)
)
## Test Cookbook
response = api_client.get(api_routes.explore_cookbooks_group_slug_item_id(unique_user.group_id, cookbook.id))
if is_private_group or is_private_cookbook:
assert response.status_code == 404
return
assert response.status_code == 200
cookbook_data = response.json()
assert cookbook_data["id"] == str(cookbook.id)
def test_get_cookbooks_with_recipes(api_client: TestClient, unique_user: TestUser, database: AllRepositories):
# Create a public and private recipe with a known tag
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = False
database.group_preferences.update(group.id, group.preferences)
tag = database.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id))
public_recipe, private_recipe = database.recipes.create_many(
Recipe(user_id=unique_user.user_id, group_id=unique_user.group_id, name=random_string()) for _ in range(2)
)
assert public_recipe.settings
public_recipe.settings.public = True
public_recipe.tags = [tag]
assert private_recipe.settings
private_recipe.settings.public = False
private_recipe.tags = [tag]
database.recipes.update_many([public_recipe, private_recipe])
# Create a public cookbook with tag
cookbook = database.cookbooks.create(
SaveCookBook(name=random_string(), group_id=unique_user.group_id, public=True, tags=[tag])
)
database.cookbooks.create(cookbook)
# Get the cookbook and make sure we only get the public recipe
response = api_client.get(api_routes.explore_cookbooks_group_slug_item_id(unique_user.group_id, cookbook.id))
assert response.status_code == 200
cookbook_data = response.json()
assert cookbook_data["id"] == str(cookbook.id)
cookbook_recipe_ids: set[str] = {recipe["id"] for recipe in cookbook_data["recipes"]}
assert len(cookbook_recipe_ids) == 1
assert str(public_recipe.id) in cookbook_recipe_ids
assert str(private_recipe.id) not in cookbook_recipe_ids

View File

@ -0,0 +1,69 @@
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_ingredient import SaveIngredientFood
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_all_foods(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Foods
foods = database.ingredient_foods.create_many(
[SaveIngredientFood(name=random_string(), group_id=unique_user.group_id) for _ in range(random_int(15, 20))]
)
## Test Foods
response = api_client.get(api_routes.explore_foods_group_slug(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
foods_data = response.json()
fetched_ids: set[str] = {food["id"] for food in foods_data["items"]}
for food in foods:
assert str(food.id) in fetched_ids
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_one_food(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Food
food = database.ingredient_foods.create(SaveIngredientFood(name=random_string(), group_id=unique_user.group_id))
## Test Food
response = api_client.get(api_routes.explore_foods_group_slug_item_id(unique_user.group_id, food.id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
food_data = response.json()
assert food_data["id"] == str(food.id)

View File

@ -0,0 +1,142 @@
from enum import Enum
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe_category import CategorySave, TagSave
from mealie.schema.recipe.recipe_tool import RecipeToolSave
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
class OrganizerType(Enum):
categories = "categories"
tags = "tags"
tools = "tools"
@pytest.mark.parametrize(
"organizer_type, is_private_group",
[
(OrganizerType.categories, True),
(OrganizerType.categories, False),
(OrganizerType.tags, True),
(OrganizerType.tags, False),
(OrganizerType.tools, True),
(OrganizerType.tools, False),
],
ids=[
"private_group_categories",
"public_group_categories",
"private_group_tags",
"public_group_tags",
"private_group_tools",
"public_group_tools",
],
)
def test_get_all_organizers(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
organizer_type: OrganizerType,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Organizers
if organizer_type is OrganizerType.categories:
item_class = CategorySave
repo = database.categories # type: ignore
route = api_routes.explore_organizers_group_slug_categories
elif organizer_type is OrganizerType.tags:
item_class = TagSave
repo = database.tags # type: ignore
route = api_routes.explore_organizers_group_slug_tags
else:
item_class = RecipeToolSave
repo = database.tools # type: ignore
route = api_routes.explore_organizers_group_slug_tools
organizers = repo.create_many(
[item_class(name=random_string(), group_id=unique_user.group_id) for _ in range(random_int(15, 20))]
)
## Test Organizers
response = api_client.get(route(unique_user.group_id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
organizers_data = response.json()
fetched_ids: set[str] = {organizer["id"] for organizer in organizers_data["items"]}
for organizer in organizers:
assert str(organizer.id) in fetched_ids
@pytest.mark.parametrize(
"organizer_type, is_private_group",
[
(OrganizerType.categories, True),
(OrganizerType.categories, False),
(OrganizerType.tags, True),
(OrganizerType.tags, False),
(OrganizerType.tools, True),
(OrganizerType.tools, False),
],
ids=[
"private_group_category",
"public_group_category",
"private_group_tag",
"public_group_tag",
"private_group_tool",
"public_group_tool",
],
)
def test_get_one_organizer(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
organizer_type: OrganizerType,
is_private_group: bool,
):
## Set Up Group
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
## Set Up Organizer
if organizer_type is OrganizerType.categories:
item_class = CategorySave
repo = database.categories # type: ignore
route = api_routes.explore_organizers_group_slug_categories_item_id
elif organizer_type is OrganizerType.tags:
item_class = TagSave
repo = database.tags # type: ignore
route = api_routes.explore_organizers_group_slug_tags_item_id
else:
item_class = RecipeToolSave
repo = database.tools # type: ignore
route = api_routes.explore_organizers_group_slug_tools_item_id
organizer = repo.create(item_class(name=random_string(), group_id=unique_user.group_id))
## Test Organizer
response = api_client.get(route(unique_user.group_id, organizer.id))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
organizer_data = response.json()
assert organizer_data["id"] == str(organizer.id)

View File

@ -0,0 +1,167 @@
import random
from dataclasses import dataclass
from typing import Any
import pytest
from fastapi.testclient import TestClient
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.recipe.recipe import Recipe
from tests.utils import api_routes
from tests.utils.factories import random_int, random_string
from tests.utils.fixture_schemas import TestUser
@dataclass(slots=True)
class PublicRecipeTestCase:
private_group: bool
public_recipe: bool
status_code: int
error: str | None
@pytest.mark.parametrize("is_private_group", [True, False], ids=["group_is_private", "group_is_public"])
def test_get_all_public_recipes(
api_client: TestClient,
unique_user: TestUser,
database: AllRepositories,
is_private_group: bool,
):
## Set Up Public and Private Recipes
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = is_private_group
database.group_preferences.update(group.id, group.preferences)
default_recipes = database.recipes.create_many(
[
Recipe(
user_id=unique_user.user_id,
group_id=unique_user.group_id,
name=random_string(),
)
for _ in range(random_int(15, 20))
],
)
random.shuffle(default_recipes)
split_index = random_int(6, 12)
public_recipes = default_recipes[:split_index]
private_recipes = default_recipes[split_index:]
for recipe in public_recipes:
assert recipe.settings
recipe.settings.public = True
for recipe in private_recipes:
assert recipe.settings
recipe.settings.public = False
database.recipes.update_many(public_recipes + private_recipes)
## Query All Recipes
response = api_client.get(api_routes.explore_recipes_group_slug(group.slug))
if is_private_group:
assert response.status_code == 404
return
assert response.status_code == 200
recipes_data = response.json()
fetched_ids: set[str] = {recipe["id"] for recipe in recipes_data["items"]}
for recipe in public_recipes:
assert str(recipe.id) in fetched_ids
for recipe in private_recipes:
assert str(recipe.id) not in fetched_ids
@pytest.mark.parametrize(
"query_filter, recipe_data, should_fetch",
[
('slug = "mypublicslug"', {"slug": "mypublicslug"}, True),
('slug = "mypublicslug"', {"slug": "notmypublicslug"}, False),
("settings.public = FALSE", {}, False),
("settings.public <> TRUE", {}, False),
],
ids=[
"match_slug",
"not_match_slug",
"bypass_public_filter_1",
"bypass_public_filter_2",
],
)
def test_get_all_public_recipes_filtered(
api_client: TestClient,
unique_user: TestUser,
random_recipe: Recipe,
database: AllRepositories,
query_filter: str,
recipe_data: dict[str, Any],
should_fetch: bool,
):
## Set Up Recipe
group = database.groups.get_one(unique_user.group_id)
assert group and group.preferences
group.preferences.private_group = False
database.group_preferences.update(group.id, group.preferences)
assert random_recipe.settings
random_recipe.settings.public = True
database.recipes.update(random_recipe.slug, random_recipe.dict() | recipe_data)
## Query All Recipes
response = api_client.get(api_routes.explore_recipes_group_slug(group.slug), params={"queryFilter": query_filter})
assert response.status_code == 200
recipes_data = response.json()
fetched_ids: set[str] = {recipe["id"] for recipe in recipes_data["items"]}
assert should_fetch is (str(random_recipe.id) in fetched_ids)
@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)
assert group and group.preferences
group.preferences.private_group = test_case.private_group
database.group_preferences.update(group.id, group.preferences)
# Set Recipe `settings.public` attribute
assert random_recipe.settings
random_recipe.settings.public = test_case.public_recipe
database.recipes.update(random_recipe.slug, random_recipe)
# Try to access recipe
recipe_group = database.groups.get_by_slug_or_id(random_recipe.group_id)
assert recipe_group
response = api_client.get(
api_routes.explore_recipes_group_slug_recipe_slug(
recipe_group.slug,
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

View File

@ -225,6 +225,61 @@ def comments_item_id(item_id):
return f"{prefix}/comments/{item_id}"
def explore_cookbooks_group_slug(group_slug):
"""`/api/explore/cookbooks/{group_slug}`"""
return f"{prefix}/explore/cookbooks/{group_slug}"
def explore_cookbooks_group_slug_item_id(group_slug, item_id):
"""`/api/explore/cookbooks/{group_slug}/{item_id}`"""
return f"{prefix}/explore/cookbooks/{group_slug}/{item_id}"
def explore_foods_group_slug(group_slug):
"""`/api/explore/foods/{group_slug}`"""
return f"{prefix}/explore/foods/{group_slug}"
def explore_foods_group_slug_item_id(group_slug, item_id):
"""`/api/explore/foods/{group_slug}/{item_id}`"""
return f"{prefix}/explore/foods/{group_slug}/{item_id}"
def explore_organizers_group_slug_categories(group_slug):
"""`/api/explore/organizers/{group_slug}/categories`"""
return f"{prefix}/explore/organizers/{group_slug}/categories"
def explore_organizers_group_slug_categories_item_id(group_slug, item_id):
"""`/api/explore/organizers/{group_slug}/categories/{item_id}`"""
return f"{prefix}/explore/organizers/{group_slug}/categories/{item_id}"
def explore_organizers_group_slug_tags(group_slug):
"""`/api/explore/organizers/{group_slug}/tags`"""
return f"{prefix}/explore/organizers/{group_slug}/tags"
def explore_organizers_group_slug_tags_item_id(group_slug, item_id):
"""`/api/explore/organizers/{group_slug}/tags/{item_id}`"""
return f"{prefix}/explore/organizers/{group_slug}/tags/{item_id}"
def explore_organizers_group_slug_tools(group_slug):
"""`/api/explore/organizers/{group_slug}/tools`"""
return f"{prefix}/explore/organizers/{group_slug}/tools"
def explore_organizers_group_slug_tools_item_id(group_slug, item_id):
"""`/api/explore/organizers/{group_slug}/tools/{item_id}`"""
return f"{prefix}/explore/organizers/{group_slug}/tools/{item_id}"
def explore_recipes_group_slug(group_slug):
"""`/api/explore/recipes/{group_slug}`"""
return f"{prefix}/explore/recipes/{group_slug}"
def explore_recipes_group_slug_recipe_slug(group_slug, recipe_slug):
"""`/api/explore/recipes/{group_slug}/{recipe_slug}`"""
return f"{prefix}/explore/recipes/{group_slug}/{recipe_slug}"