mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -04:00 
			
		
		
		
	feat: additional cookbook features (tags, tools, and public) (#1116)
* migration: add public, tags, and tools * generate frontend types * add help icon * start replacement for tool-tag-category selector * add help icon utility * use generator types * add support for cookbook features * add UI elements for cookbook features * fix tests * fix type error
This commit is contained in:
		| @@ -1,32 +1,18 @@ | ||||
| import { BaseCRUDAPI } from "../_base"; | ||||
| import { CategoryBase } from "~/types/api-types/recipe"; | ||||
| import { RecipeCategory } from "~/types/api-types/user"; | ||||
| import { CreateCookBook, RecipeCookBook, UpdateCookBook } from "~/types/api-types/cookbook"; | ||||
|  | ||||
| const prefix = "/api"; | ||||
|  | ||||
| export interface CreateCookBook { | ||||
|   name: string; | ||||
| } | ||||
|  | ||||
| export interface CookBook extends CreateCookBook { | ||||
|   id: number; | ||||
|   slug: string; | ||||
|   description: string; | ||||
|   position: number; | ||||
|   group_id: number; | ||||
|   categories: RecipeCategory[] | CategoryBase[]; | ||||
| } | ||||
|  | ||||
| const routes = { | ||||
|   cookbooks: `${prefix}/groups/cookbooks`, | ||||
|   cookbooksId: (id: number) => `${prefix}/groups/cookbooks/${id}`, | ||||
| }; | ||||
|  | ||||
| export class CookbookAPI extends BaseCRUDAPI<CookBook, CreateCookBook> { | ||||
| export class CookbookAPI extends BaseCRUDAPI<RecipeCookBook, CreateCookBook> { | ||||
|   baseRoute: string = routes.cookbooks; | ||||
|   itemRoute = routes.cookbooksId; | ||||
|  | ||||
|   async updateAll(payload: CookBook[]) { | ||||
|   async updateAll(payload: UpdateCookBook[]) { | ||||
|     return await this.requests.put(this.baseRoute, payload); | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										109
									
								
								frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								frontend/components/Domain/Recipe/RecipeOrganizerSelector.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| <template> | ||||
|   <v-autocomplete | ||||
|     v-model="selected" | ||||
|     :items="items" | ||||
|     :value="value" | ||||
|     :label="label" | ||||
|     chips | ||||
|     deletable-chips | ||||
|     item-text="name" | ||||
|     multiple | ||||
|     :prepend-inner-icon="$globals.icons.tags" | ||||
|     return-object | ||||
|     v-bind="inputAttrs" | ||||
|   > | ||||
|     <template #selection="data"> | ||||
|       <v-chip | ||||
|         :key="data.index" | ||||
|         class="ma-1" | ||||
|         :input-value="data.selected" | ||||
|         close | ||||
|         label | ||||
|         color="accent" | ||||
|         dark | ||||
|         @click:close="removeByIndex(data.index)" | ||||
|       > | ||||
|         {{ data.item.name || data.item }} | ||||
|       </v-chip> | ||||
|     </template> | ||||
|   </v-autocomplete> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, useContext } from "@nuxtjs/composition-api"; | ||||
| import { computed, onMounted } from "vue-demi"; | ||||
| import { RecipeCategory, RecipeTag } from "~/types/api-types/user"; | ||||
| import { RecipeTool } from "~/types/api-types/admin"; | ||||
|  | ||||
| type OrganizerType = "tag" | "category" | "tool"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   props: { | ||||
|     value: { | ||||
|       type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[] | undefined, | ||||
|       required: true, | ||||
|     }, | ||||
|     /** | ||||
|      * The type of organizer to use. | ||||
|      */ | ||||
|     selectorType: { | ||||
|       type: String as () => OrganizerType, | ||||
|       required: true, | ||||
|     }, | ||||
|     /** | ||||
|      * List of items that are available to be chosen from | ||||
|      */ | ||||
|     items: { | ||||
|       type: Array as () => (RecipeTag | RecipeCategory | RecipeTool)[], | ||||
|       required: true, | ||||
|     }, | ||||
|     inputAttrs: { | ||||
|       type: Object as () => Record<string, any>, | ||||
|       default: () => ({}), | ||||
|     }, | ||||
|   }, | ||||
|  | ||||
|   setup(props, context) { | ||||
|     const selected = computed({ | ||||
|       get: () => props.value, | ||||
|       set: (val) => { | ||||
|         context.emit("input", val); | ||||
|       }, | ||||
|     }); | ||||
|  | ||||
|     onMounted(() => { | ||||
|       if (selected.value === undefined) { | ||||
|         selected.value = []; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     const { i18n } = useContext(); | ||||
|  | ||||
|     const label = computed(() => { | ||||
|       switch (props.selectorType) { | ||||
|         case "tag": | ||||
|           return i18n.t("tag.tags"); | ||||
|         case "category": | ||||
|           return i18n.t("category.categories"); | ||||
|         case "tool": | ||||
|           return "Tools"; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     function removeByIndex(index: number) { | ||||
|       if (selected.value === undefined) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const newSelected = selected.value.filter((_, i) => i !== index); | ||||
|       selected.value = [...newSelected]; | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       label, | ||||
|       selected, | ||||
|       removeByIndex, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
							
								
								
									
										28
									
								
								frontend/components/global/HelpIcon.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								frontend/components/global/HelpIcon.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| <template> | ||||
|   <div class="text-center"> | ||||
|     <v-menu top offset-y left open-on-hover> | ||||
|       <template #activator="{ on, attrs }"> | ||||
|         <v-btn icon v-bind="attrs" v-on="on" @click.stop> | ||||
|           <v-icon> {{ $globals.icons.help }} </v-icon> | ||||
|         </v-btn> | ||||
|       </template> | ||||
|       <v-card max-width="300px"> | ||||
|         <v-card-text> | ||||
|           <slot></slot> | ||||
|         </v-card-text> | ||||
|       </v-card> | ||||
|     </v-menu> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   setup() { | ||||
|     return {}; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped></style> | ||||
| @@ -1,9 +1,9 @@ | ||||
| import { useAsync, ref, Ref } from "@nuxtjs/composition-api"; | ||||
| import { useAsyncKey } from "./use-utils"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import { CookBook } from "~/api/class-interfaces/group-cookbooks"; | ||||
| import { ReadCookBook, RecipeCookBook, UpdateCookBook } from "~/types/api-types/cookbook"; | ||||
|  | ||||
| let cookbookStore: Ref<CookBook[] | null> | null = null; | ||||
| let cookbookStore: Ref<ReadCookBook[] | null> | null = null; | ||||
|  | ||||
| export const useCookbook = function () { | ||||
|   function getOne(id: string | number) { | ||||
| @@ -60,13 +60,13 @@ export const useCookbooks = function () { | ||||
|  | ||||
|       loading.value = false; | ||||
|     }, | ||||
|     async updateOne(updateData: CookBook) { | ||||
|     async updateOne(updateData: UpdateCookBook) { | ||||
|       if (!updateData.id) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       loading.value = true; | ||||
|       const { data } = await api.cookbooks.updateOne(updateData.id, updateData); | ||||
|       const { data } = await api.cookbooks.updateOne(updateData.id, updateData as RecipeCookBook); | ||||
|       if (data && cookbookStore?.value) { | ||||
|         this.refreshAll(); | ||||
|       } | ||||
|   | ||||
| @@ -41,7 +41,7 @@ | ||||
|             <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> | ||||
|             <v-list-item-title>{{ $t("sidebar.language") }}</v-list-item-title> | ||||
|             <LanguageDialog v-model="languageDialog" /> | ||||
|           </v-list-item-content> | ||||
|         </v-list-item> | ||||
| @@ -103,7 +103,7 @@ export default defineComponent({ | ||||
|         return { | ||||
|           icon: $globals.icons.pages, | ||||
|           title: cookbook.name, | ||||
|           to: `/cookbooks/${cookbook.slug}`, | ||||
|           to: `/cookbooks/${cookbook.slug as string}`, | ||||
|         }; | ||||
|       }); | ||||
|     }); | ||||
|   | ||||
| @@ -9,16 +9,10 @@ | ||||
|         {{ book.description }} | ||||
|       </v-card-text> | ||||
|     </v-card> | ||||
|     <v-tabs v-model="tab" show-arrows> | ||||
|       <v-tab v-for="(cat, index) in book.categories" :key="index"> | ||||
|         {{ cat.name }} | ||||
|       </v-tab> | ||||
|     </v-tabs> | ||||
|     <v-tabs-items v-model="tab"> | ||||
|       <v-tab-item v-for="(cat, idx) in book.categories" :key="`tabs` + idx"> | ||||
|         <RecipeCardSection class="mb-5 mx-1" :recipes="cat.recipes" /> | ||||
|       </v-tab-item> | ||||
|     </v-tabs-items> | ||||
|  | ||||
|     <v-container class="pa-0"> | ||||
|       <RecipeCardSection class="mb-5 mx-1" :recipes="book.recipes" /> | ||||
|     </v-container> | ||||
|   </v-container> | ||||
| </template> | ||||
|  | ||||
| @@ -26,6 +20,7 @@ | ||||
| import { defineComponent, useRoute, ref, useMeta } from "@nuxtjs/composition-api"; | ||||
| import RecipeCardSection from "@/components/Domain/Recipe/RecipeCardSection.vue"; | ||||
| import { useCookbook } from "~/composables/use-group-cookbooks"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { RecipeCardSection }, | ||||
|   setup() { | ||||
| @@ -51,6 +46,3 @@ export default defineComponent({ | ||||
|   head: {}, // Must include for useMeta | ||||
| }); | ||||
| </script> | ||||
|  | ||||
| <style scoped> | ||||
| </style> | ||||
| @@ -5,7 +5,9 @@ | ||||
|         <v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img> | ||||
|       </template> | ||||
|       <template #title> Cookbooks </template> | ||||
|       Arrange and edit your cookbooks here. | ||||
|       Cookbooks are another way to organize recipes by creating cross sections of recipes and tags. Creating a cookbook | ||||
|       will add an entry to the side-bar and all the recipes with the tags and categories chosen will be displayed in the | ||||
|       cookbook. | ||||
|     </BasePageTitle> | ||||
|  | ||||
|     <BaseButton create @click="actions.createOne()" /> | ||||
| @@ -31,10 +33,24 @@ | ||||
|             </template> | ||||
|           </v-expansion-panel-header> | ||||
|           <v-expansion-panel-content> | ||||
|             <v-card-text> | ||||
|             <v-card-text v-if="cookbooks"> | ||||
|               <v-text-field v-model="cookbooks[index].name" label="Cookbook Name"></v-text-field> | ||||
|               <v-textarea v-model="cookbooks[index].description" auto-grow :rows="2" label="Description"></v-textarea> | ||||
|               <DomainRecipeCategoryTagSelector v-model="cookbooks[index].categories" /> | ||||
|               <RecipeOrganizerSelector | ||||
|                 v-model="cookbooks[index].categories" | ||||
|                 :items="allCategories || []" | ||||
|                 selector-type="category" | ||||
|               /> | ||||
|               <RecipeOrganizerSelector v-model="cookbooks[index].tags" :items="allTags || []" selector-type="tag" /> | ||||
|               <RecipeOrganizerSelector v-model="cookbooks[index].tools" :items="tools || []" selector-type="tool" /> | ||||
|               <v-switch v-model="cookbooks[index].public"> | ||||
|                 <template #label> | ||||
|                   Public Cookbook | ||||
|                   <HelpIcon class="ml-4"> | ||||
|                     Public Cookbooks can be shared with non-mealie users and will be displayed on your groups page. | ||||
|                   </HelpIcon> | ||||
|                 </template> | ||||
|               </v-switch> | ||||
|             </v-card-text> | ||||
|             <v-card-actions> | ||||
|               <v-spacer></v-spacer> | ||||
| @@ -42,12 +58,12 @@ | ||||
|                 :buttons="[ | ||||
|                   { | ||||
|                     icon: $globals.icons.delete, | ||||
|                     text: $t('general.delete'), | ||||
|                     text: $tc('general.delete'), | ||||
|                     event: 'delete', | ||||
|                   }, | ||||
|                   { | ||||
|                     icon: $globals.icons.save, | ||||
|                     text: $t('general.save'), | ||||
|                     text: $tc('general.save'), | ||||
|                     event: 'save', | ||||
|                   }, | ||||
|                 ]" | ||||
| @@ -66,15 +82,27 @@ | ||||
| import { defineComponent } from "@nuxtjs/composition-api"; | ||||
| import draggable from "vuedraggable"; | ||||
| import { useCookbooks } from "@/composables/use-group-cookbooks"; | ||||
| import RecipeOrganizerSelector from "~/components/Domain/Recipe/RecipeOrganizerSelector.vue"; | ||||
| import { useCategories, useTags, useTools } from "~/composables/recipes"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { draggable }, | ||||
|   components: { draggable, RecipeOrganizerSelector }, | ||||
|   setup() { | ||||
|     const { cookbooks, actions } = useCookbooks(); | ||||
|  | ||||
|     const { tools } = useTools(); | ||||
|     const { allCategories, useAsyncGetAll: getAllCategories } = useCategories(); | ||||
|     const { allTags, useAsyncGetAll: getAllTags } = useTags(); | ||||
|  | ||||
|     getAllCategories(); | ||||
|     getAllTags(); | ||||
|  | ||||
|     return { | ||||
|       allCategories, | ||||
|       allTags, | ||||
|       cookbooks, | ||||
|       actions, | ||||
|       tools, | ||||
|     }; | ||||
|   }, | ||||
|   head() { | ||||
|   | ||||
| @@ -124,6 +124,7 @@ export interface RecipeIngredient { | ||||
|   food?: IngredientFood | CreateIngredientFood; | ||||
|   disableAmount?: boolean; | ||||
|   quantity?: number; | ||||
|   originalText?: string; | ||||
|   referenceId?: string; | ||||
| } | ||||
| export interface IngredientUnit { | ||||
|   | ||||
| @@ -15,22 +15,46 @@ export interface CreateCookBook { | ||||
|   description?: string; | ||||
|   slug?: string; | ||||
|   position?: number; | ||||
|   public?: boolean; | ||||
|   categories?: CategoryBase[]; | ||||
|   tags?: TagBase[]; | ||||
|   tools?: RecipeTool[]; | ||||
| } | ||||
| export interface TagBase { | ||||
|   name: string; | ||||
|   id: string; | ||||
|   slug: string; | ||||
| } | ||||
| export interface RecipeTool { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   slug: string; | ||||
|   onHand?: boolean; | ||||
| } | ||||
| export interface ReadCookBook { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   slug?: string; | ||||
|   position?: number; | ||||
|   public?: boolean; | ||||
|   categories?: CategoryBase[]; | ||||
|   tags?: TagBase[]; | ||||
|   tools?: RecipeTool[]; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
| } | ||||
| export interface RecipeCategoryResponse { | ||||
| export interface RecipeCookBook { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   slug?: string; | ||||
|   position?: number; | ||||
|   public?: boolean; | ||||
|   categories?: CategoryBase[]; | ||||
|   tags?: TagBase[]; | ||||
|   tools?: RecipeTool[]; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
|   slug: string; | ||||
|   recipes?: RecipeSummary[]; | ||||
|   recipes: RecipeSummary[]; | ||||
| } | ||||
| export interface RecipeSummary { | ||||
|   id?: string; | ||||
| @@ -64,12 +88,6 @@ export interface RecipeTag { | ||||
|   name: string; | ||||
|   slug: string; | ||||
| } | ||||
| export interface RecipeTool { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   slug: string; | ||||
|   onHand?: boolean; | ||||
| } | ||||
| export interface RecipeIngredient { | ||||
|   title?: string; | ||||
|   note?: string; | ||||
| @@ -77,6 +95,7 @@ export interface RecipeIngredient { | ||||
|   food?: IngredientFood | CreateIngredientFood; | ||||
|   disableAmount?: boolean; | ||||
|   quantity?: number; | ||||
|   originalText?: string; | ||||
|   referenceId?: string; | ||||
| } | ||||
| export interface IngredientUnit { | ||||
| @@ -110,21 +129,15 @@ export interface CreateIngredientFood { | ||||
|   description?: string; | ||||
|   labelId?: string; | ||||
| } | ||||
| export interface RecipeCookBook { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   slug?: string; | ||||
|   position?: number; | ||||
|   categories: RecipeCategoryResponse[]; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
| } | ||||
| export interface SaveCookBook { | ||||
|   name: string; | ||||
|   description?: string; | ||||
|   slug?: string; | ||||
|   position?: number; | ||||
|   public?: boolean; | ||||
|   categories?: CategoryBase[]; | ||||
|   tags?: TagBase[]; | ||||
|   tools?: RecipeTool[]; | ||||
|   groupId: string; | ||||
| } | ||||
| export interface UpdateCookBook { | ||||
| @@ -132,7 +145,10 @@ export interface UpdateCookBook { | ||||
|   description?: string; | ||||
|   slug?: string; | ||||
|   position?: number; | ||||
|   public?: boolean; | ||||
|   categories?: CategoryBase[]; | ||||
|   tags?: TagBase[]; | ||||
|   tools?: RecipeTool[]; | ||||
|   groupId: string; | ||||
|   id: string; | ||||
| } | ||||
|   | ||||
| @@ -279,6 +279,7 @@ export interface RecipeIngredient { | ||||
|   food?: IngredientFood | CreateIngredientFood; | ||||
|   disableAmount?: boolean; | ||||
|   quantity?: number; | ||||
|   originalText?: string; | ||||
|   referenceId?: string; | ||||
| } | ||||
| export interface CreateIngredientUnit { | ||||
|   | ||||
| @@ -140,6 +140,7 @@ export interface RecipeIngredient { | ||||
|   food?: IngredientFood | CreateIngredientFood; | ||||
|   disableAmount?: boolean; | ||||
|   quantity?: number; | ||||
|   originalText?: string; | ||||
|   referenceId?: string; | ||||
| } | ||||
| export interface IngredientUnit { | ||||
|   | ||||
| @@ -153,6 +153,7 @@ export interface RecipeIngredient { | ||||
|   food?: IngredientFood | CreateIngredientFood; | ||||
|   disableAmount?: boolean; | ||||
|   quantity?: number; | ||||
|   originalText?: string; | ||||
|   referenceId?: string; | ||||
| } | ||||
| export interface IngredientUnit { | ||||
|   | ||||
| @@ -5,6 +5,7 @@ export interface Icon { | ||||
|   // General | ||||
|   chart: string; | ||||
|   wrench: string; | ||||
|   help: string; | ||||
|   bowlMixOutline: string; | ||||
|   foods: string; | ||||
|   units: string; | ||||
|   | ||||
| @@ -107,6 +107,7 @@ import { | ||||
|   mdiBowlMixOutline, | ||||
|   mdiWrench, | ||||
|   mdiChartLine, | ||||
|   mdiHelpCircleOutline, | ||||
| } from "@mdi/js"; | ||||
|  | ||||
| export const icons = { | ||||
| @@ -118,6 +119,7 @@ export const icons = { | ||||
|  | ||||
|   // General | ||||
|   bowlMixOutline: mdiBowlMixOutline, | ||||
|   help: mdiHelpCircleOutline, | ||||
|   foods: mdiFoodApple, | ||||
|   units: mdiBeakerOutline, | ||||
|   alert: mdiAlert, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user