mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-30 17:53:31 -04:00 
			
		
		
		
	Feature/move label editor (#1069)
* update default color * move labels editor
This commit is contained in:
		| @@ -29,7 +29,6 @@ export default defineComponent({ | ||||
|  | ||||
|     Based on -> https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color | ||||
|     */ | ||||
|  | ||||
|     const ACCESSIBILITY_THRESHOLD = 0.179; | ||||
|  | ||||
|     function pickTextColorBasedOnBgColorAdvanced(bgColor: string, lightColor: string, darkColor: string) { | ||||
|   | ||||
| @@ -24,11 +24,11 @@ | ||||
|                 value: 'new', | ||||
|                 to: '/group/data/units', | ||||
|               }, | ||||
|               // { | ||||
|               //   text: 'Labels', | ||||
|               //   value: 'new', | ||||
|               //   to: '/group/data/labels', | ||||
|               // }, | ||||
|               { | ||||
|                 text: 'Labels', | ||||
|                 value: 'new', | ||||
|                 to: '/group/data/labels', | ||||
|               }, | ||||
|             ]" | ||||
|           > | ||||
|           </BaseOverflowButton> | ||||
|   | ||||
| @@ -27,7 +27,7 @@ | ||||
|     <!-- Delete Dialog --> | ||||
|     <BaseDialog | ||||
|       v-model="deleteDialog" | ||||
|       :title="$tc('general.delete')" | ||||
|       :title="$tc('general.confirm')" | ||||
|       :icon="$globals.icons.alertCircle" | ||||
|       color="error" | ||||
|       @confirm="deleteFood" | ||||
|   | ||||
							
								
								
									
										198
									
								
								frontend/pages/group/data/labels.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								frontend/pages/group/data/labels.vue
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,198 @@ | ||||
| <template> | ||||
|   <div> | ||||
|     <!-- Create New Dialog --> | ||||
|     <BaseDialog v-model="state.createDialog" title="New Label" :icon="$globals.icons.tags" @submit="createLabel"> | ||||
|       <v-card-text> | ||||
|         <MultiPurposeLabel :label="createLabelData" /> | ||||
|  | ||||
|         <div class="mt-4"> | ||||
|           <v-text-field v-model="createLabelData.name" :label="$t('general.name')"> </v-text-field> | ||||
|           <InputColor v-model="createLabelData.color" /> | ||||
|         </div> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <!-- Edit Dialog --> | ||||
|     <BaseDialog | ||||
|       v-model="state.editDialog" | ||||
|       :icon="$globals.icons.tags" | ||||
|       title="Edit Label" | ||||
|       :submit-text="$tc('general.save')" | ||||
|       @submit="editSaveLabel" | ||||
|     > | ||||
|       <v-card-text v-if="editLabel"> | ||||
|         <MultiPurposeLabel :label="editLabel" /> | ||||
|         <div class="mt-4"> | ||||
|           <v-text-field v-model="editLabel.name" :label="$t('general.name')"> </v-text-field> | ||||
|           <InputColor v-model="editLabel.color" /> | ||||
|         </div> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <!-- Delete Dialog --> | ||||
|     <BaseDialog | ||||
|       v-model="state.deleteDialog" | ||||
|       :title="$tc('general.confirm')" | ||||
|       :icon="$globals.icons.alertCircle" | ||||
|       color="error" | ||||
|       @confirm="deleteLabel" | ||||
|     > | ||||
|       <v-card-text> | ||||
|         {{ $t("general.confirm-delete-generic") }} | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <!-- Recipe Data Table --> | ||||
|     <BaseCardSectionTitle :icon="$globals.icons.tags" section title="Labels"> </BaseCardSectionTitle> | ||||
|     <CrudTable | ||||
|       :table-config="tableConfig" | ||||
|       :headers.sync="tableHeaders" | ||||
|       :data="labels" | ||||
|       :bulk-actions="[]" | ||||
|       @delete-one="deleteEventHandler" | ||||
|       @edit-one="editEventHandler" | ||||
|     > | ||||
|       <template #button-row> | ||||
|         <BaseButton create @click="state.createDialog = true"> | ||||
|           <template #icon> {{ $globals.icons.tags }} </template> | ||||
|           Create | ||||
|         </BaseButton> | ||||
|       </template> | ||||
|       <template #item.name="{ item }"> | ||||
|         <MultiPurposeLabel v-if="item" :label="item"> | ||||
|           {{ item.name }} | ||||
|         </MultiPurposeLabel> | ||||
|       </template> | ||||
|     </CrudTable> | ||||
|   </div> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, reactive, ref } from "@nuxtjs/composition-api"; | ||||
| import { validators } from "~/composables/use-validators"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue"; | ||||
| import { MultiPurposeLabelSummary } from "~/types/api-types/labels"; | ||||
|  | ||||
| export default defineComponent({ | ||||
|   components: { MultiPurposeLabel }, | ||||
|   setup() { | ||||
|     const userApi = useUserApi(); | ||||
|     const tableConfig = { | ||||
|       hideColumns: true, | ||||
|       canExport: true, | ||||
|     }; | ||||
|     const tableHeaders = [ | ||||
|       { | ||||
|         text: "Id", | ||||
|         value: "id", | ||||
|         show: false, | ||||
|       }, | ||||
|       { | ||||
|         text: "Name", | ||||
|         value: "name", | ||||
|         show: true, | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
|     const state = reactive({ | ||||
|       createDialog: false, | ||||
|       editDialog: false, | ||||
|       deleteDialog: false, | ||||
|     }); | ||||
|  | ||||
|     // ============================================================ | ||||
|     // Labels | ||||
|  | ||||
|     const labels = ref([] as MultiPurposeLabelSummary[]); | ||||
|  | ||||
|     async function refreshLabels() { | ||||
|       const { data } = await userApi.multiPurposeLabels.getAll(); | ||||
|       labels.value = data ?? []; | ||||
|     } | ||||
|  | ||||
|     // Create | ||||
|  | ||||
|     const createLabelData = ref({ | ||||
|       groupId: "", | ||||
|       id: "", | ||||
|       name: "", | ||||
|       color: "", | ||||
|     }); | ||||
|  | ||||
|     async function createLabel() { | ||||
|       await userApi.multiPurposeLabels.createOne(createLabelData.value); | ||||
|       createLabelData.value = { | ||||
|         groupId: "", | ||||
|         id: "", | ||||
|         name: "", | ||||
|         color: "", | ||||
|       }; | ||||
|       refreshLabels(); | ||||
|       state.createDialog = false; | ||||
|     } | ||||
|  | ||||
|     // Delete | ||||
|  | ||||
|     const deleteTarget = ref<MultiPurposeLabelSummary | null>(null); | ||||
|  | ||||
|     function deleteEventHandler(item: MultiPurposeLabelSummary) { | ||||
|       state.deleteDialog = true; | ||||
|       deleteTarget.value = item; | ||||
|     } | ||||
|  | ||||
|     async function deleteLabel() { | ||||
|       if (!deleteTarget.value) { | ||||
|         return; | ||||
|       } | ||||
|       const { data } = await userApi.multiPurposeLabels.deleteOne(deleteTarget.value.id); | ||||
|       if (data) { | ||||
|         refreshLabels(); | ||||
|       } | ||||
|       state.deleteDialog = false; | ||||
|     } | ||||
|  | ||||
|     // Edit | ||||
|  | ||||
|     const editLabel = ref<MultiPurposeLabelSummary | null>(null); | ||||
|  | ||||
|     function editEventHandler(item: MultiPurposeLabelSummary) { | ||||
|       state.editDialog = true; | ||||
|       editLabel.value = item; | ||||
|  | ||||
|       if (!editLabel.value.color) { | ||||
|         editLabel.value.color = "#E0E0E0"; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     async function editSaveLabel() { | ||||
|       if (!editLabel.value) { | ||||
|         return; | ||||
|       } | ||||
|       const { data } = await userApi.multiPurposeLabels.updateOne(editLabel.value.id, editLabel.value); | ||||
|       if (data) { | ||||
|         refreshLabels(); | ||||
|       } | ||||
|       state.editDialog = false; | ||||
|     } | ||||
|  | ||||
|     refreshLabels(); | ||||
|  | ||||
|     return { | ||||
|       state, | ||||
|       tableConfig, | ||||
|       tableHeaders, | ||||
|       labels, | ||||
|       validators, | ||||
|  | ||||
|       deleteEventHandler, | ||||
|       deleteLabel, | ||||
|       editLabel, | ||||
|       editEventHandler, | ||||
|       editSaveLabel, | ||||
|       createLabel, | ||||
|       createLabelData, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
| @@ -21,7 +21,7 @@ | ||||
|     <!-- Delete Dialog --> | ||||
|     <BaseDialog | ||||
|       v-model="deleteDialog" | ||||
|       :title="$tc('general.delete')" | ||||
|       :title="$tc('general.confirm')" | ||||
|       :icon="$globals.icons.alertCircle" | ||||
|       color="error" | ||||
|       @confirm="deleteFood" | ||||
|   | ||||
| @@ -178,7 +178,7 @@ | ||||
|  | ||||
|     <v-lazy> | ||||
|       <div class="d-flex justify-end mt-10"> | ||||
|         <ButtonLink to="/shopping-lists/labels" text="Manage Labels" :icon="$globals.icons.tags" /> | ||||
|         <ButtonLink to="/group/data/labels" text="Manage Labels" :icon="$globals.icons.tags" /> | ||||
|       </div> | ||||
|     </v-lazy> | ||||
|   </v-container> | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| <template> | ||||
|   <v-container v-if="shoppingLists" class="narrow-container"> | ||||
|     <BaseDialog v-model="createDialog" :title="$t('shopping-list.create-shopping-list')" @submit="createOne"> | ||||
|     <BaseDialog v-model="createDialog" :title="$tc('shopping-list.create-shopping-list')" @submit="createOne"> | ||||
|       <v-card-text> | ||||
|         <v-text-field v-model="createName" autofocus :label="$t('shopping-list.new-list')"> </v-text-field> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <BaseDialog v-model="deleteDialog" :title="$t('general.confirm')" color="error" @confirm="deleteOne"> | ||||
|     <BaseDialog v-model="deleteDialog" :title="$tc('general.confirm')" color="error" @confirm="deleteOne"> | ||||
|       <v-card-text> Are you sure you want to delete this item?</v-card-text> | ||||
|     </BaseDialog> | ||||
|     <BasePageTitle divider> | ||||
| @@ -33,7 +33,7 @@ | ||||
|       </v-card> | ||||
|     </section> | ||||
|     <div class="d-flex justify-end mt-10"> | ||||
|       <ButtonLink to="/shopping-lists/labels" text="Manage Labels" :icon="$globals.icons.tags" /> | ||||
|       <ButtonLink to="/group/data/labels" text="Manage Labels" :icon="$globals.icons.tags" /> | ||||
|     </div> | ||||
|   </v-container> | ||||
| </template> | ||||
|   | ||||
| @@ -1,266 +0,0 @@ | ||||
| <template> | ||||
|   <v-container class="narrow-container"> | ||||
|     <BaseDialog v-model="createDialog" title="New Label" :icon="$globals.icons.tags" @submit="createLabel"> | ||||
|       <v-card-text> | ||||
|         <v-text-field v-model="createLabelData.name" :label="$t('general.name')"> </v-text-field> | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <BaseDialog | ||||
|       v-model="deleteDialog" | ||||
|       :title="$t('general.confirm')" | ||||
|       :icon="$globals.icons.alert" | ||||
|       color="error" | ||||
|       @confirm="confirmDelete" | ||||
|     > | ||||
|       <v-card-text> | ||||
|         {{ $t("general.confirm-delete-generic") }} | ||||
|       </v-card-text> | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <BasePageTitle divider> | ||||
|       <template #header> | ||||
|         <v-img max-height="100" max-width="100" :src="require('~/static/svgs/shopping-cart.svg')"></v-img> | ||||
|       </template> | ||||
|       <template #title> Shopping Lists Labels </template> | ||||
|     </BasePageTitle> | ||||
|     <BaseButton create @click="createDialog = true" /> | ||||
|  | ||||
|     <section v-if="labels" class="mt-4"> | ||||
|       <v-text-field v-model="searchInput" :label="$t('sidebar.search')" clearable> | ||||
|         <template #prepend> | ||||
|           <v-icon>{{ $globals.icons.search }}</v-icon> | ||||
|         </template> | ||||
|       </v-text-field> | ||||
|  | ||||
|       <v-sheet v-for="(label, index) in results" :key="label.id"> | ||||
|         <div class="d-flex px-2 py-2 pt-3"> | ||||
|           <MultiPurposeLabel :label="label" /> | ||||
|  | ||||
|           <div class="ml-auto"> | ||||
|             <v-btn v-if="!isOpen[label.id]" class="mx-1" icon @click.prevent="deleteLabel(label.id)"> | ||||
|               <v-icon> | ||||
|                 {{ $globals.icons.delete }} | ||||
|               </v-icon> | ||||
|             </v-btn> | ||||
|             <v-btn v-if="!isOpen[label.id]" class="mx-1" icon @click="toggleIsOpen(label)"> | ||||
|               <v-icon> | ||||
|                 {{ $globals.icons.edit }} | ||||
|               </v-icon> | ||||
|             </v-btn> | ||||
|           </div> | ||||
|         </div> | ||||
|         <v-card-text v-if="isOpen[label.id]"> | ||||
|           <div class="d-md-flex" style="gap: 30px"> | ||||
|             <v-text-field v-model="labels[index].name" :label="$t('general.name')"> </v-text-field> | ||||
|             <div style="max-width: 300px"> | ||||
|               <InputColor v-model="labels[index].color" /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="d-flex justify-end"> | ||||
|             <BaseButtonGroup | ||||
|               :buttons="[ | ||||
|                 { | ||||
|                   icon: $globals.icons.delete, | ||||
|                   text: 'Delete', | ||||
|                   event: 'delete', | ||||
|                 }, | ||||
|                 { | ||||
|                   icon: $globals.icons.close, | ||||
|                   text: 'Cancel', | ||||
|                   event: 'cancel', | ||||
|                 }, | ||||
|                 { | ||||
|                   icon: $globals.icons.save, | ||||
|                   text: 'Save', | ||||
|                   event: 'save', | ||||
|                 }, | ||||
|               ]" | ||||
|               @cancel="resetToLastGoodValue(label, index)" | ||||
|               @save="updateLabel(label)" | ||||
|               @delete="deleteLabel(label.id)" | ||||
|             /> | ||||
|           </div> | ||||
|         </v-card-text> | ||||
|         <v-divider></v-divider> | ||||
|       </v-sheet> | ||||
|     </section> | ||||
|   </v-container> | ||||
| </template> | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { defineComponent, ref, useAsync, computed } from "@nuxtjs/composition-api"; | ||||
| import Fuse from "fuse.js"; | ||||
| import MultiPurposeLabel from "~/components/Domain/ShoppingList/MultiPurposeLabel.vue"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import { useAsyncKey } from "~/composables/use-utils"; | ||||
| import { MultiPurposeLabelSummary } from "~/types/api-types/labels"; | ||||
| export default defineComponent({ | ||||
|   components: { MultiPurposeLabel }, | ||||
|   setup() { | ||||
|     // ========================================================== | ||||
|     // API Operations | ||||
|  | ||||
|     const api = useUserApi(); | ||||
|  | ||||
|     const deleteDialog = ref(false); | ||||
|     const deleteTargetId = ref(""); | ||||
|  | ||||
|     async function confirmDelete() { | ||||
|       await api.multiPurposeLabels.deleteOne(deleteTargetId.value); | ||||
|       refreshLabels(); | ||||
|       deleteTargetId.value = ""; | ||||
|     } | ||||
|  | ||||
|     function deleteLabel(itemId: string) { | ||||
|       deleteTargetId.value = itemId; | ||||
|       deleteDialog.value = true; | ||||
|     } | ||||
|  | ||||
|     const createDialog = ref(false); | ||||
|  | ||||
|     const createLabelData = ref({ | ||||
|       name: "", | ||||
|       color: "", | ||||
|     }); | ||||
|  | ||||
|     async function createLabel() { | ||||
|       createLabelData.value.color = getRandomHex(); | ||||
|       const { data } = await api.multiPurposeLabels.createOne(createLabelData.value); | ||||
|       if (data) { | ||||
|         refreshLabels(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     async function updateLabel(label: MultiPurposeLabelSummary) { | ||||
|       const { data } = await api.multiPurposeLabels.updateOne(label.id, label); | ||||
|       if (data) { | ||||
|         refreshLabels(); | ||||
|         toggleIsOpen(label); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const labels = useAsync(async () => { | ||||
|       const { data } = await api.multiPurposeLabels.getAll(); | ||||
|       return data; | ||||
|     }, useAsyncKey()); | ||||
|  | ||||
|     async function refreshLabels() { | ||||
|       const { data } = await api.multiPurposeLabels.getAll(); | ||||
|       labels.value = data ?? []; | ||||
|     } | ||||
|  | ||||
|     // ========================================================== | ||||
|     // Component Helpers | ||||
|  | ||||
|     const lastGoodValue = ref<{ [key: string]: MultiPurposeLabelSummary }>({}); | ||||
|  | ||||
|     function saveLastGoodValue(label: MultiPurposeLabelSummary) { | ||||
|       lastGoodValue.value[label.id] = { ...label }; | ||||
|     } | ||||
|  | ||||
|     function resetToLastGoodValue(label: MultiPurposeLabelSummary, index: number) { | ||||
|       const lgv = lastGoodValue.value[label.id]; | ||||
|  | ||||
|       if (lgv && labels.value) { | ||||
|         labels.value[index] = lgv; | ||||
|         labels.value = [...labels.value]; | ||||
|       } | ||||
|  | ||||
|       toggleIsOpen(label); | ||||
|     } | ||||
|  | ||||
|     const isOpen = ref<{ [key: string]: boolean }>({}); | ||||
|  | ||||
|     function toggleIsOpen(label: MultiPurposeLabelSummary) { | ||||
|       isOpen.value[label.id] = !isOpen.value[label.id]; | ||||
|  | ||||
|       if (isOpen.value[label.id]) { | ||||
|         saveLastGoodValue(label); | ||||
|       } | ||||
|  | ||||
|       isOpen.value = { ...isOpen.value }; | ||||
|     } | ||||
|  | ||||
|     // ========================================================== | ||||
|     // Color Generators | ||||
|  | ||||
|     function getRandomHex() { | ||||
|       const letters = "BCDEF".split(""); | ||||
|       let color = "#"; | ||||
|       for (let i = 0; i < 6; i++) { | ||||
|         color += letters[Math.floor(Math.random() * letters.length)]; | ||||
|       } | ||||
|  | ||||
|       return color; | ||||
|     } | ||||
|  | ||||
|     function setRandomHex(labelIndex: number) { | ||||
|       if (!labels.value) { | ||||
|         return; | ||||
|       } | ||||
|       labels.value[labelIndex].color = getRandomHex(); | ||||
|       labels.value = [...labels.value]; | ||||
|     } | ||||
|  | ||||
|     // ========================================================== | ||||
|     // Search / Filter | ||||
|  | ||||
|     const searchInput = ref(""); | ||||
|  | ||||
|     const labelNames = computed(() => { | ||||
|       return labels.value?.map((label) => label.name) ?? []; | ||||
|     }); | ||||
|  | ||||
|     const fuseOpts = { | ||||
|       shouldSort: true, | ||||
|       threshold: 0.5, | ||||
|       location: 0, | ||||
|       distance: 100, | ||||
|       findAllMatches: true, | ||||
|       maxPatternLength: 32, | ||||
|       minMatchCharLength: 2, | ||||
|       keys: ["name"], | ||||
|     }; | ||||
|  | ||||
|     const fuse = computed(() => { | ||||
|       return new Fuse(labelNames.value, fuseOpts); | ||||
|     }); | ||||
|  | ||||
|     const results = computed(() => { | ||||
|       if (!searchInput.value) { | ||||
|         return labels.value; | ||||
|       } | ||||
|  | ||||
|       const foundName = fuse.value.search(searchInput.value).map((result) => result.item); | ||||
|  | ||||
|       return labels.value?.filter((label) => foundName.includes(label.name)) ?? []; | ||||
|     }); | ||||
|  | ||||
|     return { | ||||
|       saveLastGoodValue, | ||||
|       resetToLastGoodValue, | ||||
|       deleteDialog, | ||||
|       deleteTargetId, | ||||
|       confirmDelete, | ||||
|       createLabelData, | ||||
|       createLabel, | ||||
|       createDialog, | ||||
|       results, | ||||
|       searchInput, | ||||
|       updateLabel, | ||||
|       deleteLabel, | ||||
|       setRandomHex, | ||||
|       toggleIsOpen, | ||||
|       isOpen, | ||||
|       labels, | ||||
|       refreshLabels, | ||||
|     }; | ||||
|   }, | ||||
|   head: { | ||||
|     title: "Shopping List Labels", | ||||
|   }, | ||||
| }); | ||||
| </script> | ||||
|  | ||||
|  | ||||
| @@ -6,7 +6,7 @@ from pydantic import UUID4 | ||||
|  | ||||
| class MultiPurposeLabelCreate(CamelModel): | ||||
|     name: str | ||||
|     color: str = "" | ||||
|     color: str = "#E0E0E0" | ||||
|  | ||||
|  | ||||
| class MultiPurposeLabelSave(MultiPurposeLabelCreate): | ||||
|   | ||||
		Reference in New Issue
	
	Block a user