mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 02:03:35 -04:00 
			
		
		
		
	feat: Remove Not-Sort-By-Label and Refactor Shopping List Page (#5866)
Co-authored-by: Kuchenpirat <24235032+Kuchenpirat@users.noreply.github.com>
This commit is contained in:
		| @@ -1,5 +1,5 @@ | ||||
| <template> | ||||
|   <v-list :class="tile ? 'd-flex flex-wrap background' : 'background'"> | ||||
|   <v-list :class="tile ? 'd-flex flex-wrap background' : 'background'" style="background-color: transparent;"> | ||||
|     <v-sheet | ||||
|       v-for="recipe, index in recipes" | ||||
|       :key="recipe.id" | ||||
|   | ||||
| @@ -20,6 +20,7 @@ | ||||
|         <template #activator="{ props }"> | ||||
|           <v-btn | ||||
|             size="small" | ||||
|             variant="text" | ||||
|             class="ml-2 handle" | ||||
|             icon | ||||
|             v-bind="props" | ||||
|   | ||||
| @@ -13,7 +13,7 @@ | ||||
|             v-model="listItem.checked" | ||||
|             hide-details | ||||
|             density="compact" | ||||
|             class="mt-0" | ||||
|             class="mt-0 flex-shrink-0" | ||||
|             color="null" | ||||
|             @change="$emit('checked', listItem)" | ||||
|           /> | ||||
| @@ -27,16 +27,6 @@ | ||||
|         </div> | ||||
|       </v-col> | ||||
|       <v-spacer /> | ||||
|       <v-col | ||||
|         v-if="label && showLabel" | ||||
|         cols="3" | ||||
|         class="text-right" | ||||
|       > | ||||
|         <MultiPurposeLabel | ||||
|           :label="label" | ||||
|           size="small" | ||||
|         /> | ||||
|       </v-col> | ||||
|       <v-col | ||||
|         cols="auto" | ||||
|         class="text-right" | ||||
| @@ -75,27 +65,6 @@ | ||||
|                 </template> | ||||
|                 <span>Toggle Recipes</span> | ||||
|               </v-tooltip> | ||||
|               <!-- Dummy button so the spacing is consistent when labels are enabled --> | ||||
|               <v-btn | ||||
|                 v-else | ||||
|                 size="small" | ||||
|                 variant="text" | ||||
|                 class="ml-2" | ||||
|                 icon | ||||
|                 disabled | ||||
|               /> | ||||
|  | ||||
|               <v-btn | ||||
|                 size="small" | ||||
|                 variant="text" | ||||
|                 class="ml-2 handle" | ||||
|                 icon | ||||
|                 v-bind="props" | ||||
|               > | ||||
|                 <v-icon> | ||||
|                   {{ $globals.icons.arrowUpDown }} | ||||
|                 </v-icon> | ||||
|               </v-btn> | ||||
|               <v-btn | ||||
|                 size="small" | ||||
|                 variant="text" | ||||
| @@ -107,6 +76,17 @@ | ||||
|                   {{ $globals.icons.edit }} | ||||
|                 </v-icon> | ||||
|               </v-btn> | ||||
|               <v-btn | ||||
|                 size="small" | ||||
|                 variant="text" | ||||
|                 class="handle" | ||||
|                 icon | ||||
|                 v-bind="props" | ||||
|               > | ||||
|                 <v-icon> | ||||
|                   {{ $globals.icons.arrowUpDown }} | ||||
|                 </v-icon> | ||||
|               </v-btn> | ||||
|             </template> | ||||
|             <v-list density="compact"> | ||||
|               <v-list-item | ||||
| @@ -177,7 +157,6 @@ | ||||
| import { useOnline } from "@vueuse/core"; | ||||
| import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; | ||||
| import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; | ||||
| import MultiPurposeLabel from "./MultiPurposeLabel.vue"; | ||||
| import type { ShoppingListItemOut } from "~/lib/api/types/household"; | ||||
| import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels"; | ||||
| import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; | ||||
| @@ -189,16 +168,12 @@ interface actions { | ||||
| } | ||||
|  | ||||
| export default defineNuxtComponent({ | ||||
|   components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem }, | ||||
|   components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem }, | ||||
|   props: { | ||||
|     modelValue: { | ||||
|       type: Object as () => ShoppingListItemOut, | ||||
|       required: true, | ||||
|     }, | ||||
|     showLabel: { | ||||
|       type: Boolean, | ||||
|       default: false, | ||||
|     }, | ||||
|     labels: { | ||||
|       type: Array as () => MultiPurposeLabelOut[], | ||||
|       required: true, | ||||
| @@ -220,7 +195,7 @@ export default defineNuxtComponent({ | ||||
|   setup(props, context) { | ||||
|     const i18n = useI18n(); | ||||
|     const displayRecipeRefs = ref(false); | ||||
|     const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : props.showLabel ? "4" : "6"); | ||||
|     const itemLabelCols = ref<string>(props.modelValue.checked ? "auto" : "6"); | ||||
|     const isOffline = computed(() => useOnline().value === false); | ||||
|  | ||||
|     const contextMenu: actions[] = [ | ||||
| @@ -305,7 +280,7 @@ export default defineNuxtComponent({ | ||||
|       } | ||||
|  | ||||
|       listItem.value.recipeReferences.forEach((ref) => { | ||||
|         const recipe = props.recipes.get(ref.recipeId); | ||||
|         const recipe = props.recipes?.get(ref.recipeId); | ||||
|         if (recipe) { | ||||
|           recipeList.push(recipe); | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,50 @@ | ||||
| import type { ShoppingListItemOut } from "~/lib/api/types/household"; | ||||
| import { useCopyList } from "~/composables/use-copy"; | ||||
|  | ||||
| type CopyTypes = "plain" | "markdown"; | ||||
|  | ||||
| /** | ||||
|  * Composable for managing shopping list copy functionality | ||||
|  */ | ||||
| export function useShoppingListCopy() { | ||||
|   const copy = useCopyList(); | ||||
|  | ||||
|   function copyListItems(itemsByLabel: { [key: string]: ShoppingListItemOut[] }, copyType: CopyTypes) { | ||||
|     const text: string[] = []; | ||||
|     Object.entries(itemsByLabel).forEach(([label, items], idx) => { | ||||
|       if (idx) { | ||||
|         text.push(""); | ||||
|       } | ||||
|  | ||||
|       text.push(formatCopiedLabelHeading(copyType, label)); | ||||
|       items.forEach(item => text.push(formatCopiedListItem(copyType, item))); | ||||
|     }); | ||||
|  | ||||
|     copy.copyPlain(text); | ||||
|   } | ||||
|  | ||||
|   function formatCopiedListItem(copyType: CopyTypes, item: ShoppingListItemOut): string { | ||||
|     const display = item.display || ""; | ||||
|     switch (copyType) { | ||||
|       case "markdown": | ||||
|         return `- [ ] ${display}`; | ||||
|       default: | ||||
|         return display; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function formatCopiedLabelHeading(copyType: CopyTypes, label: string): string { | ||||
|     switch (copyType) { | ||||
|       case "markdown": | ||||
|         return `# ${label}`; | ||||
|       default: | ||||
|         return `[${label}]`; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     copyListItems, | ||||
|     formatCopiedListItem, | ||||
|     formatCopiedLabelHeading, | ||||
|   }; | ||||
| } | ||||
| @@ -0,0 +1,263 @@ | ||||
| import type { ShoppingListOut, ShoppingListItemOut, ShoppingListMultiPurposeLabelOut } from "~/lib/api/types/household"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import { uuid4 } from "~/composables/use-utils"; | ||||
|  | ||||
| /** | ||||
|  * Composable for managing shopping list item CRUD operations | ||||
|  */ | ||||
| export function useShoppingListCrud( | ||||
|   shoppingList: Ref<ShoppingListOut | null>, | ||||
|   loadingCounter: Ref<number>, | ||||
|   listItems: { unchecked: ShoppingListItemOut[]; checked: ShoppingListItemOut[] }, | ||||
|   shoppingListItemActions: any, | ||||
|   refresh: () => void, | ||||
|   sortCheckedItems: (a: ShoppingListItemOut, b: ShoppingListItemOut) => number, | ||||
|   updateListItemOrder: () => void, | ||||
| ) { | ||||
|   const { t } = useI18n(); | ||||
|   const userApi = useUserApi(); | ||||
|  | ||||
|   const createListItemData = ref<ShoppingListItemOut>(listItemFactory()); | ||||
|   const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>(); | ||||
|  | ||||
|   function listItemFactory(): ShoppingListItemOut { | ||||
|     return { | ||||
|       id: uuid4(), | ||||
|       shoppingListId: shoppingList.value?.id || "", | ||||
|       checked: false, | ||||
|       position: shoppingList.value?.listItems?.length || 1, | ||||
|       quantity: 0, | ||||
|       note: "", | ||||
|       labelId: undefined, | ||||
|       unitId: undefined, | ||||
|       foodId: undefined, | ||||
|     } as ShoppingListItemOut; | ||||
|   } | ||||
|  | ||||
|   // Check/Uncheck All operations | ||||
|   function checkAllItems() { | ||||
|     let hasChanged = false; | ||||
|     shoppingList.value?.listItems?.forEach((item) => { | ||||
|       if (!item.checked) { | ||||
|         hasChanged = true; | ||||
|         item.checked = true; | ||||
|       } | ||||
|     }); | ||||
|     if (hasChanged) { | ||||
|       updateUncheckedListItems(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function uncheckAllItems() { | ||||
|     let hasChanged = false; | ||||
|     shoppingList.value?.listItems?.forEach((item) => { | ||||
|       if (item.checked) { | ||||
|         hasChanged = true; | ||||
|         item.checked = false; | ||||
|       } | ||||
|     }); | ||||
|     if (hasChanged) { | ||||
|       listItems.unchecked = [...listItems.unchecked, ...listItems.checked]; | ||||
|       listItems.checked = []; | ||||
|       updateUncheckedListItems(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function deleteCheckedItems() { | ||||
|     const checked = shoppingList.value?.listItems?.filter(item => item.checked); | ||||
|  | ||||
|     if (!checked || checked?.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     loadingCounter.value += 1; | ||||
|     deleteListItems(checked); | ||||
|     loadingCounter.value -= 1; | ||||
|     refresh(); | ||||
|   } | ||||
|  | ||||
|   function saveListItem(item: ShoppingListItemOut) { | ||||
|     if (!shoppingList.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // set a temporary updatedAt timestamp prior to refresh so it appears at the top of the checked items | ||||
|     item.updatedAt = new Date().toISOString(); | ||||
|  | ||||
|     // make updates reflect immediately | ||||
|     if (shoppingList.value.listItems) { | ||||
|       shoppingList.value.listItems.forEach((oldListItem: ShoppingListItemOut, idx: number) => { | ||||
|         if (oldListItem.id === item.id && shoppingList.value?.listItems) { | ||||
|           shoppingList.value.listItems[idx] = item; | ||||
|         } | ||||
|       }); | ||||
|       // Immediately update checked/unchecked arrays for UI | ||||
|       listItems.unchecked = shoppingList.value.listItems.filter(i => !i.checked); | ||||
|       listItems.checked = shoppingList.value.listItems.filter(i => i.checked) | ||||
|         .sort(sortCheckedItems); | ||||
|     } | ||||
|  | ||||
|     // Update the item if it's checked, otherwise updateUncheckedListItems will handle it | ||||
|     if (item.checked) { | ||||
|       shoppingListItemActions.updateItem(item); | ||||
|     } | ||||
|  | ||||
|     updateListItemOrder(); | ||||
|     updateUncheckedListItems(); | ||||
|   } | ||||
|  | ||||
|   function deleteListItem(item: ShoppingListItemOut) { | ||||
|     if (!shoppingList.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     shoppingListItemActions.deleteItem(item); | ||||
|  | ||||
|     // remove the item from the list immediately so the user sees the change | ||||
|     if (shoppingList.value.listItems) { | ||||
|       shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => itm.id !== item.id); | ||||
|     } | ||||
|  | ||||
|     refresh(); | ||||
|   } | ||||
|  | ||||
|   function deleteListItems(items: ShoppingListItemOut[]) { | ||||
|     if (!shoppingList.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     items.forEach((item) => { | ||||
|       shoppingListItemActions.deleteItem(item); | ||||
|     }); | ||||
|     // remove the items from the list immediately so the user sees the change | ||||
|     if (shoppingList.value?.listItems) { | ||||
|       const deletedItems = new Set(items.map(item => item.id)); | ||||
|       shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => !deletedItems.has(itm.id)); | ||||
|     } | ||||
|  | ||||
|     refresh(); | ||||
|   } | ||||
|  | ||||
|   function createListItem() { | ||||
|     if (!shoppingList.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!createListItemData.value.foodId && !createListItemData.value.note) { | ||||
|       // don't create an empty item | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     loadingCounter.value += 1; | ||||
|  | ||||
|     // make sure it's inserted into the end of the list, which may have been updated | ||||
|     createListItemData.value.position = shoppingList.value?.listItems?.length | ||||
|       ? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1 | ||||
|       : 0; | ||||
|  | ||||
|     createListItemData.value.createdAt = new Date().toISOString(); | ||||
|     createListItemData.value.updatedAt = createListItemData.value.createdAt; | ||||
|  | ||||
|     updateListItemOrder(); | ||||
|  | ||||
|     shoppingListItemActions.createItem(createListItemData.value); | ||||
|     loadingCounter.value -= 1; | ||||
|  | ||||
|     if (shoppingList.value.listItems) { | ||||
|       // add the item to the list immediately so the user sees the change | ||||
|       shoppingList.value.listItems.push(createListItemData.value); | ||||
|       updateListItemOrder(); | ||||
|     } | ||||
|     createListItemData.value = listItemFactory(); | ||||
|     refresh(); | ||||
|   } | ||||
|  | ||||
|   function updateUncheckedListItems() { | ||||
|     if (!shoppingList.value?.listItems) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Set position for unchecked items | ||||
|     listItems.unchecked.forEach((item: ShoppingListItemOut, idx: number) => { | ||||
|       item.position = idx; | ||||
|       shoppingListItemActions.updateItem(item); | ||||
|     }); | ||||
|  | ||||
|     refresh(); | ||||
|   } | ||||
|  | ||||
|   // Label management | ||||
|   function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) { | ||||
|     if (!shoppingList.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     labelSettings.forEach((labelSetting, index) => { | ||||
|       labelSetting.position = index; | ||||
|       return labelSetting; | ||||
|     }); | ||||
|  | ||||
|     localLabels.value = labelSettings; | ||||
|   } | ||||
|  | ||||
|   function cancelLabelOrder() { | ||||
|     loadingCounter.value -= 1; | ||||
|     if (!shoppingList.value) { | ||||
|       return; | ||||
|     } | ||||
|     // restore original state | ||||
|     localLabels.value = shoppingList.value.labelSettings; | ||||
|   } | ||||
|  | ||||
|   async function saveLabelOrder(updateItemsByLabel: () => void) { | ||||
|     if (!shoppingList.value || !localLabels.value || (localLabels.value === shoppingList.value.labelSettings)) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     loadingCounter.value += 1; | ||||
|     const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, localLabels.value); | ||||
|     loadingCounter.value -= 1; | ||||
|  | ||||
|     if (data) { | ||||
|       // update shoppingList labels using the API response | ||||
|       shoppingList.value.labelSettings = (data as ShoppingListOut).labelSettings; | ||||
|       updateItemsByLabel(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function toggleReorderLabelsDialog(reorderLabelsDialog: Ref<boolean>) { | ||||
|     // stop polling and populate localLabels | ||||
|     loadingCounter.value += 1; | ||||
|     reorderLabelsDialog.value = !reorderLabelsDialog.value; | ||||
|     localLabels.value = shoppingList.value?.labelSettings; | ||||
|   } | ||||
|  | ||||
|   // Context menu actions | ||||
|   const contextActions = { | ||||
|     delete: "delete", | ||||
|   }; | ||||
|  | ||||
|   const contextMenu = [ | ||||
|     { title: t("general.delete"), action: contextActions.delete }, | ||||
|   ]; | ||||
|  | ||||
|   return { | ||||
|     createListItemData, | ||||
|     localLabels, | ||||
|     listItemFactory, | ||||
|     checkAllItems, | ||||
|     uncheckAllItems, | ||||
|     deleteCheckedItems, | ||||
|     saveListItem, | ||||
|     deleteListItem, | ||||
|     deleteListItems, | ||||
|     createListItem, | ||||
|     updateUncheckedListItems, | ||||
|     updateLabelOrder, | ||||
|     cancelLabelOrder, | ||||
|     saveLabelOrder, | ||||
|     toggleReorderLabelsDialog, | ||||
|     contextActions, | ||||
|     contextMenu, | ||||
|   }; | ||||
| } | ||||
| @@ -0,0 +1,117 @@ | ||||
| import { useOnline, useIdle } from "@vueuse/core"; | ||||
| import type { ShoppingListOut } from "~/lib/api/types/household"; | ||||
| import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions"; | ||||
|  | ||||
| /** | ||||
|  * Composable for managing shopping list data fetching and polling | ||||
|  */ | ||||
| export function useShoppingListData(listId: string, shoppingList: Ref<ShoppingListOut | null>, loadingCounter: Ref<number>) { | ||||
|   const isOffline = computed(() => useOnline().value === false); | ||||
|   const { idle } = useIdle(5 * 60 * 1000); // 5 minutes | ||||
|   const shoppingListItemActions = useShoppingListItemActions(listId); | ||||
|  | ||||
|   async function fetchShoppingList() { | ||||
|     const data = await shoppingListItemActions.getList(); | ||||
|     return data; | ||||
|   } | ||||
|  | ||||
|   async function refresh(updateListItemOrder: () => void) { | ||||
|     loadingCounter.value += 1; | ||||
|     try { | ||||
|       await shoppingListItemActions.process(); | ||||
|     } | ||||
|     catch (error) { | ||||
|       console.error(error); | ||||
|     } | ||||
|  | ||||
|     let newListValue: typeof shoppingList.value = null; | ||||
|     try { | ||||
|       newListValue = await fetchShoppingList(); | ||||
|     } | ||||
|     catch (error) { | ||||
|       console.error(error); | ||||
|     } | ||||
|  | ||||
|     loadingCounter.value -= 1; | ||||
|  | ||||
|     // only update the list with the new value if we're not loading, to prevent UI jitter | ||||
|     if (loadingCounter.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Prevent overwriting local changes with stale backend data when offline | ||||
|     if (isOffline.value) { | ||||
|       // Do not update shoppingList.value from backend when offline | ||||
|       updateListItemOrder(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // if we're not connected to the network, this will be null, so we don't want to clear the list | ||||
|     if (newListValue) { | ||||
|       shoppingList.value = newListValue; | ||||
|     } | ||||
|  | ||||
|     updateListItemOrder(); | ||||
|   } | ||||
|  | ||||
|   // constantly polls for changes | ||||
|   async function pollForChanges(updateListItemOrder: () => void) { | ||||
|     // pause polling if the user isn't active or we're busy | ||||
|     if (idle.value || loadingCounter.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await refresh(updateListItemOrder); | ||||
|  | ||||
|       if (shoppingList.value) { | ||||
|         attempts = 0; | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // if the refresh was unsuccessful, the shopping list will be null, so we increment the attempt counter | ||||
|       attempts++; | ||||
|     } | ||||
|     catch { | ||||
|       attempts++; | ||||
|     } | ||||
|  | ||||
|     // if we hit too many errors, stop polling | ||||
|     if (attempts >= maxAttempts) { | ||||
|       clearInterval(pollTimer); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // start polling | ||||
|   loadingCounter.value -= 1; | ||||
|  | ||||
|   // max poll time = pollFrequency * maxAttempts = 24 hours | ||||
|   // we use a long max poll time since polling stops when the user is idle anyway | ||||
|   const pollFrequency = 5000; | ||||
|   const maxAttempts = 17280; | ||||
|   let attempts = 0; | ||||
|   let pollTimer: ReturnType<typeof setInterval>; | ||||
|  | ||||
|   function startPolling(updateListItemOrder: () => void) { | ||||
|     pollForChanges(updateListItemOrder); // populate initial list | ||||
|  | ||||
|     pollTimer = setInterval(() => { | ||||
|       pollForChanges(updateListItemOrder); | ||||
|     }, pollFrequency); | ||||
|   } | ||||
|  | ||||
|   function stopPolling() { | ||||
|     if (pollTimer) { | ||||
|       clearInterval(pollTimer); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     isOffline, | ||||
|     fetchShoppingList, | ||||
|     refresh, | ||||
|     startPolling, | ||||
|     stopPolling, | ||||
|     shoppingListItemActions, | ||||
|   }; | ||||
| } | ||||
| @@ -0,0 +1,73 @@ | ||||
| import { useToggle } from "@vueuse/core"; | ||||
| import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household"; | ||||
|  | ||||
| /** | ||||
|  * Composable for managing shopping list label state and operations | ||||
|  */ | ||||
| export function useShoppingListLabels(shoppingList: Ref<ShoppingListOut | null>) { | ||||
|   const { t } = useI18n(); | ||||
|   const labelOpenState = ref<{ [key: string]: boolean }>({}); | ||||
|   const [showChecked, toggleShowChecked] = useToggle(false); | ||||
|  | ||||
|   const initializeLabelOpenStates = () => { | ||||
|     if (!shoppingList.value?.listItems) return; | ||||
|  | ||||
|     const existingLabels = new Set(Object.keys(labelOpenState.value)); | ||||
|     let hasChanges = false; | ||||
|  | ||||
|     for (const item of shoppingList.value.listItems) { | ||||
|       const labelName = item.label?.name || t("shopping-list.no-label"); | ||||
|       if (!existingLabels.has(labelName) && !(labelName in labelOpenState.value)) { | ||||
|         labelOpenState.value[labelName] = true; | ||||
|         hasChanges = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (hasChanges) { | ||||
|       labelOpenState.value = { ...labelOpenState.value }; | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const labelNames = computed(() => { | ||||
|     return new Set( | ||||
|       shoppingList.value?.listItems | ||||
|         ?.map(item => item.label?.name || t("shopping-list.no-label")) | ||||
|         .filter(Boolean) ?? [], | ||||
|     ); | ||||
|   }); | ||||
|  | ||||
|   watch(labelNames, initializeLabelOpenStates, { immediate: true }); | ||||
|  | ||||
|   function toggleShowLabel(key: string) { | ||||
|     labelOpenState.value[key] = !labelOpenState.value[key]; | ||||
|   } | ||||
|  | ||||
|   function getLabelColor(item: ShoppingListItemOut | null) { | ||||
|     return item?.label?.color; | ||||
|   } | ||||
|  | ||||
|   const presentLabels = computed(() => { | ||||
|     const labels: Array<{ id: string; name: string }> = []; | ||||
|  | ||||
|     shoppingList.value?.listItems?.forEach((item) => { | ||||
|       if (item.labelId && item.label) { | ||||
|         labels.push({ | ||||
|           name: item.label.name, | ||||
|           id: item.labelId, | ||||
|         }); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     return labels; | ||||
|   }); | ||||
|  | ||||
|   return { | ||||
|     labelOpenState, | ||||
|     showChecked, | ||||
|     toggleShowChecked, | ||||
|     toggleShowLabel, | ||||
|     getLabelColor, | ||||
|     presentLabels, | ||||
|     initializeLabelOpenStates, | ||||
|   }; | ||||
| } | ||||
| @@ -0,0 +1,51 @@ | ||||
| import type { ShoppingListOut } from "~/lib/api/types/household"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
|  | ||||
| /** | ||||
|  * Composable for managing shopping list recipe references | ||||
|  */ | ||||
| export function useShoppingListRecipes( | ||||
|   shoppingList: Ref<ShoppingListOut | null>, | ||||
|   loadingCounter: Ref<number>, | ||||
|   recipeReferenceLoading: Ref<boolean>, | ||||
|   refresh: () => void, | ||||
| ) { | ||||
|   const userApi = useUserApi(); | ||||
|  | ||||
|   async function addRecipeReferenceToList(recipeId: string) { | ||||
|     if (!shoppingList.value || recipeReferenceLoading.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     loadingCounter.value += 1; | ||||
|     recipeReferenceLoading.value = true; | ||||
|     const { data } = await userApi.shopping.lists.addRecipes(shoppingList.value.id, [{ recipeId }]); | ||||
|     recipeReferenceLoading.value = false; | ||||
|     loadingCounter.value -= 1; | ||||
|  | ||||
|     if (data) { | ||||
|       refresh(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   async function removeRecipeReferenceToList(recipeId: string) { | ||||
|     if (!shoppingList.value || recipeReferenceLoading.value) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     loadingCounter.value += 1; | ||||
|     recipeReferenceLoading.value = true; | ||||
|     const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId); | ||||
|     recipeReferenceLoading.value = false; | ||||
|     loadingCounter.value -= 1; | ||||
|  | ||||
|     if (data) { | ||||
|       refresh(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     addRecipeReferenceToList, | ||||
|     removeRecipeReferenceToList, | ||||
|   }; | ||||
| } | ||||
| @@ -0,0 +1,135 @@ | ||||
| import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household"; | ||||
|  | ||||
| interface ListItemGroup { | ||||
|   position: number; | ||||
|   createdAt: string; | ||||
|   items: ShoppingListItemOut[]; | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Composable for managing shopping list item sorting and organization | ||||
|  */ | ||||
| export function useShoppingListSorting() { | ||||
|   const { t } = useI18n(); | ||||
|  | ||||
|   function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) { | ||||
|     // Sort by position ASC, then by createdAt ASC | ||||
|     const posA = a.position ?? 0; | ||||
|     const posB = b.position ?? 0; | ||||
|     if (posA !== posB) { | ||||
|       return posA - posB; | ||||
|     } | ||||
|     const createdA = a.createdAt ?? ""; | ||||
|     const createdB = b.createdAt ?? ""; | ||||
|     if (createdA !== createdB) { | ||||
|       return createdA < createdB ? -1 : 1; | ||||
|     } | ||||
|     return 0; | ||||
|   } | ||||
|  | ||||
|   function groupAndSortListItemsByFood(shoppingList: ShoppingListOut) { | ||||
|     if (!shoppingList?.listItems?.length) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const checkedItemKey = "__checkedItem"; | ||||
|     const listItemGroupsMap = new Map<string, ListItemGroup>(); | ||||
|     listItemGroupsMap.set(checkedItemKey, { position: Number.MAX_SAFE_INTEGER, createdAt: "", items: [] }); | ||||
|  | ||||
|     // group items by checked status, food, or note | ||||
|     shoppingList.listItems.forEach((item) => { | ||||
|       const key = item.checked | ||||
|         ? checkedItemKey | ||||
|         : item.food?.name | ||||
|           ? item.food.name | ||||
|           : item.note || ""; | ||||
|  | ||||
|       const group = listItemGroupsMap.get(key); | ||||
|       if (!group) { | ||||
|         listItemGroupsMap.set(key, { position: item.position || 0, createdAt: item.createdAt || "", items: [item] }); | ||||
|       } | ||||
|       else { | ||||
|         group.items.push(item); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     const listItemGroups = Array.from(listItemGroupsMap.values()); | ||||
|     listItemGroups.sort(sortItems); | ||||
|  | ||||
|     // sort group items, then aggregate them | ||||
|     const sortedItems: ShoppingListItemOut[] = []; | ||||
|     let nextPosition = 0; | ||||
|     listItemGroups.forEach((listItemGroup) => { | ||||
|       listItemGroup.items.sort(sortItems); | ||||
|       listItemGroup.items.forEach((item) => { | ||||
|         item.position = nextPosition; | ||||
|         nextPosition += 1; | ||||
|         sortedItems.push(item); | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     shoppingList.listItems = sortedItems; | ||||
|   } | ||||
|  | ||||
|   function sortListItems(shoppingList: ShoppingListOut) { | ||||
|     if (!shoppingList?.listItems?.length) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     shoppingList.listItems.sort(sortItems); | ||||
|   } | ||||
|  | ||||
|   function updateItemsByLabel(shoppingList: ShoppingListOut) { | ||||
|     const items: { [prop: string]: ShoppingListItemOut[] } = {}; | ||||
|     const noLabelText = t("shopping-list.no-label"); | ||||
|     const noLabel = [] as ShoppingListItemOut[]; | ||||
|  | ||||
|     shoppingList?.listItems?.forEach((item) => { | ||||
|       if (item.checked) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (item.labelId) { | ||||
|         if (item.label && item.label.name in items) { | ||||
|           items[item.label.name].push(item); | ||||
|         } | ||||
|         else if (item.label) { | ||||
|           items[item.label.name] = [item]; | ||||
|         } | ||||
|       } | ||||
|       else { | ||||
|         noLabel.push(item); | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     if (noLabel.length > 0) { | ||||
|       items[noLabelText] = noLabel; | ||||
|     } | ||||
|  | ||||
|     // sort the map by label order | ||||
|     const orderedLabelNames = shoppingList?.labelSettings?.map(labelSetting => labelSetting.label.name); | ||||
|     if (!orderedLabelNames) { | ||||
|       return items; | ||||
|     } | ||||
|  | ||||
|     const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {}; | ||||
|     if (noLabelText in items) { | ||||
|       itemsSorted[noLabelText] = items[noLabelText]; | ||||
|     } | ||||
|  | ||||
|     orderedLabelNames.forEach((labelName) => { | ||||
|       if (labelName in items) { | ||||
|         itemsSorted[labelName] = items[labelName]; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     return itemsSorted; | ||||
|   } | ||||
|  | ||||
|   return { | ||||
|     sortItems, | ||||
|     groupAndSortListItemsByFood, | ||||
|     sortListItems, | ||||
|     updateItemsByLabel, | ||||
|   }; | ||||
| } | ||||
| @@ -0,0 +1,70 @@ | ||||
| import type { ShoppingListOut, ShoppingListItemOut } from "~/lib/api/types/household"; | ||||
|  | ||||
| /** | ||||
|  * Composable for managing shopping list state and reactive data | ||||
|  */ | ||||
| export function useShoppingListState() { | ||||
|   const shoppingList = ref<ShoppingListOut | null>(null); | ||||
|   const loadingCounter = ref(1); | ||||
|   const recipeReferenceLoading = ref(false); | ||||
|   const preserveItemOrder = ref(false); | ||||
|  | ||||
|   // UI state | ||||
|   const edit = ref(false); | ||||
|   const threeDot = ref(false); | ||||
|   const reorderLabelsDialog = ref(false); | ||||
|   const createEditorOpen = ref(false); | ||||
|  | ||||
|   // Dialog states | ||||
|   const state = reactive({ | ||||
|     checkAllDialog: false, | ||||
|     uncheckAllDialog: false, | ||||
|     deleteCheckedDialog: false, | ||||
|   }); | ||||
|  | ||||
|   // Hydrate listItems from shoppingList.value?.listItems | ||||
|   const listItems = reactive({ | ||||
|     unchecked: [] as ShoppingListItemOut[], | ||||
|     checked: [] as ShoppingListItemOut[], | ||||
|   }); | ||||
|  | ||||
|   function sortCheckedItems(a: ShoppingListItemOut, b: ShoppingListItemOut) { | ||||
|     if (a.updatedAt! === b.updatedAt!) { | ||||
|       return ((a.position || 0) > (b.position || 0)) ? -1 : 1; | ||||
|     } | ||||
|     return a.updatedAt! < b.updatedAt! ? 1 : -1; | ||||
|   } | ||||
|  | ||||
|   watch( | ||||
|     () => shoppingList.value?.listItems, | ||||
|     (items) => { | ||||
|       listItems.unchecked = (items?.filter(item => !item.checked) ?? []); | ||||
|       listItems.checked = (items?.filter(item => item.checked) | ||||
|         .sort(sortCheckedItems) ?? []); | ||||
|     }, | ||||
|     { immediate: true }, | ||||
|   ); | ||||
|  | ||||
|   const recipeMap = computed(() => new Map( | ||||
|     (shoppingList.value?.recipeReferences?.map(ref => ref.recipe) ?? []) | ||||
|       .map(recipe => [recipe.id || "", recipe])), | ||||
|   ); | ||||
|  | ||||
|   const recipeList = computed(() => Array.from(recipeMap.value.values())); | ||||
|  | ||||
|   return { | ||||
|     shoppingList, | ||||
|     loadingCounter, | ||||
|     recipeReferenceLoading, | ||||
|     preserveItemOrder, | ||||
|     edit, | ||||
|     threeDot, | ||||
|     reorderLabelsDialog, | ||||
|     createEditorOpen, | ||||
|     state, | ||||
|     listItems, | ||||
|     recipeMap, | ||||
|     recipeList, | ||||
|     sortCheckedItems, | ||||
|   }; | ||||
| } | ||||
| @@ -0,0 +1,194 @@ | ||||
| import type { ShoppingListItemOut } from "~/lib/api/types/household"; | ||||
| import { useShoppingListState } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-state"; | ||||
| import { useShoppingListData } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-data"; | ||||
| import { useShoppingListSorting } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-sorting"; | ||||
| import { useShoppingListLabels } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-labels"; | ||||
| import { useShoppingListCopy } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-copy"; | ||||
| import { useShoppingListCrud } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-crud"; | ||||
| import { useShoppingListRecipes } from "~/composables/shopping-list-page/sub-composables/use-shopping-list-recipes"; | ||||
|  | ||||
| /** | ||||
|  * Main composable that orchestrates all shopping list page functionality | ||||
|  */ | ||||
| export function useShoppingListPage(listId: string) { | ||||
|   // Initialize state | ||||
|   const state = useShoppingListState(); | ||||
|   const { | ||||
|     shoppingList, | ||||
|     loadingCounter, | ||||
|     recipeReferenceLoading, | ||||
|     preserveItemOrder, | ||||
|     listItems, | ||||
|     sortCheckedItems, | ||||
|   } = state; | ||||
|  | ||||
|   // Initialize sorting functionality | ||||
|   const sorting = useShoppingListSorting(); | ||||
|   const { groupAndSortListItemsByFood, sortListItems, updateItemsByLabel } = sorting; | ||||
|  | ||||
|   // Track items organized by label | ||||
|   const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({}); | ||||
|  | ||||
|   function updateListItemOrder() { | ||||
|     if (!shoppingList.value) return; | ||||
|  | ||||
|     if (!preserveItemOrder.value) { | ||||
|       groupAndSortListItemsByFood(shoppingList.value); | ||||
|     } | ||||
|     else { | ||||
|       sortListItems(shoppingList.value); | ||||
|     } | ||||
|  | ||||
|     const labeledItems = updateItemsByLabel(shoppingList.value); | ||||
|     if (labeledItems) { | ||||
|       itemsByLabel.value = labeledItems; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Initialize data management | ||||
|   const dataManager = useShoppingListData(listId, shoppingList, loadingCounter); | ||||
|   const { isOffline, refresh: baseRefresh, startPolling, stopPolling, shoppingListItemActions } = dataManager; | ||||
|  | ||||
|   const refresh = () => baseRefresh(updateListItemOrder); | ||||
|  | ||||
|   // Initialize shopping list labels | ||||
|   const labels = useShoppingListLabels(shoppingList); | ||||
|  | ||||
|   // Initialize copy functionality | ||||
|   const copyManager = useShoppingListCopy(); | ||||
|  | ||||
|   // Initialize CRUD operations | ||||
|   const crud = useShoppingListCrud( | ||||
|     shoppingList, | ||||
|     loadingCounter, | ||||
|     listItems, | ||||
|     shoppingListItemActions, | ||||
|     refresh, | ||||
|     sortCheckedItems, | ||||
|     updateListItemOrder, | ||||
|   ); | ||||
|  | ||||
|   // Initialize recipe management | ||||
|   const recipes = useShoppingListRecipes( | ||||
|     shoppingList, | ||||
|     loadingCounter, | ||||
|     recipeReferenceLoading, | ||||
|     refresh, | ||||
|   ); | ||||
|  | ||||
|   // Handle item reordering by label | ||||
|   function updateIndexUncheckedByLabel(labelName: string, labeledUncheckedItems: ShoppingListItemOut[]) { | ||||
|     if (!itemsByLabel.value[labelName]) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // update this label's item order | ||||
|     itemsByLabel.value[labelName] = labeledUncheckedItems; | ||||
|  | ||||
|     // reset list order of all items | ||||
|     const allUncheckedItems: ShoppingListItemOut[] = []; | ||||
|     for (const labelKey in itemsByLabel.value) { | ||||
|       allUncheckedItems.push(...itemsByLabel.value[labelKey]); | ||||
|     } | ||||
|  | ||||
|     // since the user has manually reordered the list, we should preserve this order | ||||
|     preserveItemOrder.value = true; | ||||
|  | ||||
|     // save changes | ||||
|     listItems.unchecked = allUncheckedItems; | ||||
|     listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || []; | ||||
|     crud.updateUncheckedListItems(); | ||||
|   } | ||||
|  | ||||
|   // Dialog helpers | ||||
|   function openCheckAll() { | ||||
|     if (shoppingList.value?.listItems?.some(item => !item.checked)) { | ||||
|       state.state.checkAllDialog = true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function openUncheckAll() { | ||||
|     if (shoppingList.value?.listItems?.some(item => item.checked)) { | ||||
|       state.state.uncheckAllDialog = true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function openDeleteChecked() { | ||||
|     if (shoppingList.value?.listItems?.some(item => item.checked)) { | ||||
|       state.state.deleteCheckedDialog = true; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function checkAll() { | ||||
|     state.state.checkAllDialog = false; | ||||
|     crud.checkAllItems(); | ||||
|   } | ||||
|  | ||||
|   function uncheckAll() { | ||||
|     state.state.uncheckAllDialog = false; | ||||
|     crud.uncheckAllItems(); | ||||
|   } | ||||
|  | ||||
|   function deleteChecked() { | ||||
|     state.state.deleteCheckedDialog = false; | ||||
|     crud.deleteCheckedItems(); | ||||
|   } | ||||
|  | ||||
|   // Copy functionality wrapper | ||||
|   function copyListItems(copyType: "plain" | "markdown") { | ||||
|     copyManager.copyListItems(itemsByLabel.value, copyType); | ||||
|   } | ||||
|  | ||||
|   // Label reordering helpers | ||||
|   function toggleReorderLabelsDialog() { | ||||
|     crud.toggleReorderLabelsDialog(state.reorderLabelsDialog); | ||||
|   } | ||||
|  | ||||
|   async function saveLabelOrder() { | ||||
|     await crud.saveLabelOrder(() => { | ||||
|       const labeledItems = updateItemsByLabel(shoppingList.value!); | ||||
|       if (labeledItems) { | ||||
|         itemsByLabel.value = labeledItems; | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   // Lifecycle management | ||||
|   onMounted(() => { | ||||
|     startPolling(updateListItemOrder); | ||||
|   }); | ||||
|  | ||||
|   onUnmounted(() => { | ||||
|     stopPolling(); | ||||
|   }); | ||||
|  | ||||
|   return { | ||||
|     itemsByLabel, | ||||
|     isOffline, | ||||
|  | ||||
|     // Sub-composables | ||||
|     ...state, | ||||
|     ...labels, | ||||
|     ...crud, | ||||
|     ...recipes, | ||||
|  | ||||
|     // Specialized functions | ||||
|     updateIndexUncheckedByLabel, | ||||
|     copyListItems, | ||||
|  | ||||
|     // Dialog actions | ||||
|     openCheckAll, | ||||
|     openUncheckAll, | ||||
|     openDeleteChecked, | ||||
|     checkAll, | ||||
|     uncheckAll, | ||||
|     deleteChecked, | ||||
|  | ||||
|     // Label management | ||||
|     toggleReorderLabelsDialog, | ||||
|     saveLabelOrder, | ||||
|  | ||||
|     // Data refresh | ||||
|     refresh, | ||||
|   }; | ||||
| } | ||||
| @@ -33,7 +33,6 @@ export interface UserRecipePreferences { | ||||
|  | ||||
| export interface UserShoppingListPreferences { | ||||
|   viewAllLists: boolean; | ||||
|   viewByLabel: boolean; | ||||
| } | ||||
|  | ||||
| export interface UserTimelinePreferences { | ||||
| @@ -129,7 +128,6 @@ export function useShoppingListPreferences(): Ref<UserShoppingListPreferences> { | ||||
|     "shopping-list-preferences", | ||||
|     { | ||||
|       viewAllLists: false, | ||||
|       viewByLabel: true, | ||||
|     }, | ||||
|     { mergeDefaults: true }, | ||||
|     // we cast to a Ref because by default it will return an optional type ref | ||||
|   | ||||
| @@ -4,7 +4,7 @@ | ||||
|     class="md-container" | ||||
|   > | ||||
|     <BaseDialog | ||||
|       v-model="checkAllDialog" | ||||
|       v-model="state.checkAllDialog" | ||||
|       :title="$t('general.confirm')" | ||||
|       can-confirm | ||||
|       @confirm="checkAll" | ||||
| @@ -15,7 +15,7 @@ | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <BaseDialog | ||||
|       v-model="uncheckAllDialog" | ||||
|       v-model="state.uncheckAllDialog" | ||||
|       :title="$t('general.confirm')" | ||||
|       can-confirm | ||||
|       @confirm="uncheckAll" | ||||
| @@ -26,7 +26,7 @@ | ||||
|     </BaseDialog> | ||||
|  | ||||
|     <BaseDialog | ||||
|       v-model="deleteCheckedDialog" | ||||
|       v-model="state.deleteCheckedDialog" | ||||
|       :title="$t('general.confirm')" | ||||
|       can-confirm | ||||
|       @confirm="deleteChecked" | ||||
| @@ -90,11 +90,6 @@ | ||||
|                     text: '', | ||||
|                     event: 'three-dot', | ||||
|                     children: [ | ||||
|                       { | ||||
|                         icon: $globals.icons.tags, | ||||
|                         text: $t('shopping-list.toggle-label-sort'), | ||||
|                         event: 'sort-by-labels', | ||||
|                       }, | ||||
|                       { | ||||
|                         icon: $globals.icons.tags, | ||||
|                         text: $t('shopping-list.reorder-labels'), | ||||
| @@ -111,7 +106,6 @@ | ||||
|                 @edit="edit = true" | ||||
|                 @three-dot="threeDot = true" | ||||
|                 @check="openCheckAll" | ||||
|                 @sort-by-labels="sortByLabels" | ||||
|                 @copy-plain="copyListItems('plain')" | ||||
|                 @copy-markdown="copyListItems('markdown')" | ||||
|                 @reorder-labels="toggleReorderLabelsDialog()" | ||||
| @@ -159,40 +153,6 @@ | ||||
|         </BaseButton> | ||||
|       </div> | ||||
|  | ||||
|       <!-- View without Label grouping --> | ||||
|       <div v-if="!preferences.viewByLabel"> | ||||
|         <VueDraggable | ||||
|           v-model="listItems.unchecked" | ||||
|           handle=".handle" | ||||
|           :delay="250" | ||||
|           :delay-on-touch-only="true" | ||||
|           @start="loadingCounter += 1" | ||||
|           @end="loadingCounter -= 1" | ||||
|           @update:model-value="updateIndexUnchecked" | ||||
|         > | ||||
|           <v-lazy | ||||
|             v-for="(item, index) in listItems.unchecked" | ||||
|             :key="item.id" | ||||
|             class="my-2" | ||||
|           > | ||||
|             <ShoppingListItem | ||||
|               v-model="listItems.unchecked[index]" | ||||
|               class="my-2 my-sm-0" | ||||
|               :show-label="true" | ||||
|               :labels="allLabels || []" | ||||
|               :units="allUnits || []" | ||||
|               :foods="allFoods || []" | ||||
|               :recipes="recipeMap" | ||||
|               @checked="saveListItem" | ||||
|               @save="saveListItem" | ||||
|               @delete="deleteListItem(item)" | ||||
|             /> | ||||
|           </v-lazy> | ||||
|         </VueDraggable> | ||||
|       </div> | ||||
|  | ||||
|       <!-- View By Label --> | ||||
|       <div v-else> | ||||
|       <div | ||||
|         v-for="(value, key) in itemsByLabel" | ||||
|         :key="key" | ||||
| @@ -230,7 +190,6 @@ | ||||
|               > | ||||
|                 <ShoppingListItem | ||||
|                   v-model="value[index]" | ||||
|                     :show-label="false" | ||||
|                   :labels="allLabels || []" | ||||
|                   :units="allUnits || []" | ||||
|                   :foods="allFoods || []" | ||||
| @@ -244,7 +203,6 @@ | ||||
|           </div> | ||||
|         </v-expand-transition> | ||||
|       </div> | ||||
|       </div> | ||||
|  | ||||
|       <!-- Reorder Labels --> | ||||
|       <BaseDialog | ||||
| @@ -359,12 +317,12 @@ | ||||
|         </div> | ||||
|         <v-divider class="my-4" /> | ||||
|         <RecipeList | ||||
|           :recipes="Array.from(recipeMap.values())" | ||||
|           :recipes="recipeList" | ||||
|           show-description | ||||
|           :disabled="isOffline" | ||||
|         > | ||||
|           <template | ||||
|             v-for="(recipe, index) in recipeMap.values()" | ||||
|             v-for="(recipe, index) in recipeList" | ||||
|             #[`actions-${recipe.id}`] | ||||
|             :key="'item-actions-decrease' + recipe.id" | ||||
|           > | ||||
| @@ -408,26 +366,14 @@ | ||||
|  | ||||
| <script lang="ts"> | ||||
| import { VueDraggable } from "vue-draggable-plus"; | ||||
| import { useIdle, useOnline, useToggle } from "@vueuse/core"; | ||||
| import { useCopyList } from "~/composables/use-copy"; | ||||
| import { useUserApi } from "~/composables/api"; | ||||
| import MultiPurposeLabelSection from "~/components/Domain/ShoppingList/MultiPurposeLabelSection.vue"; | ||||
| import ShoppingListItem from "~/components/Domain/ShoppingList/ShoppingListItem.vue"; | ||||
| import type { ShoppingListItemOut, ShoppingListMultiPurposeLabelOut, ShoppingListOut } from "~/lib/api/types/household"; | ||||
| import RecipeList from "~/components/Domain/Recipe/RecipeList.vue"; | ||||
| import ShoppingListItemEditor from "~/components/Domain/ShoppingList/ShoppingListItemEditor.vue"; | ||||
| import { useFoodStore, useLabelStore, useUnitStore } from "~/composables/store"; | ||||
| import { useShoppingListItemActions } from "~/composables/use-shopping-list-item-actions"; | ||||
| import { useShoppingListPreferences } from "~/composables/use-users/preferences"; | ||||
| import { getTextColor } from "~/composables/use-text-color"; | ||||
| import { uuid4 } from "~/composables/use-utils"; | ||||
|  | ||||
| type CopyTypes = "plain" | "markdown"; | ||||
|  | ||||
| interface PresentLabel { | ||||
|   id: string; | ||||
|   name: string; | ||||
| } | ||||
| import { useShoppingListPage } from "~/composables/shopping-list-page/use-shopping-list-page"; | ||||
|  | ||||
| export default defineNuxtComponent({ | ||||
|   components: { | ||||
| @@ -437,809 +383,34 @@ export default defineNuxtComponent({ | ||||
|     RecipeList, | ||||
|     ShoppingListItemEditor, | ||||
|   }, | ||||
|   // middleware: "sidebase-auth", | ||||
|   setup() { | ||||
|     const { mdAndUp } = useDisplay(); | ||||
|     const i18n = useI18n(); | ||||
|     const $auth = useMealieAuth(); | ||||
|     const preferences = useShoppingListPreferences(); | ||||
|  | ||||
|     const isOffline = computed(() => useOnline().value === false); | ||||
|  | ||||
|     useSeoMeta({ | ||||
|       title: i18n.t("shopping-list.shopping-list"), | ||||
|     }); | ||||
|  | ||||
|     const { idle } = useIdle(5 * 60 * 1000); // 5 minutes | ||||
|     const loadingCounter = ref(1); | ||||
|     const recipeReferenceLoading = ref(false); | ||||
|     const userApi = useUserApi(); | ||||
|  | ||||
|     const edit = ref(false); | ||||
|     const threeDot = ref(false); | ||||
|     const reorderLabelsDialog = ref(false); | ||||
|     const preserveItemOrder = ref(false); | ||||
|  | ||||
|     const route = useRoute(); | ||||
|     const groupSlug = computed(() => route.params.groupSlug as string || $auth.user.value?.groupSlug || ""); | ||||
|     const id = route.params.id as string; | ||||
|     const shoppingListItemActions = useShoppingListItemActions(id); | ||||
|  | ||||
|     const state = reactive({ | ||||
|       checkAllDialog: false, | ||||
|       uncheckAllDialog: false, | ||||
|       deleteCheckedDialog: false, | ||||
|     }); | ||||
|  | ||||
|     // =============================================================== | ||||
|     // Shopping List Actions | ||||
|  | ||||
|     const shoppingList = ref<ShoppingListOut | null>(null); | ||||
|     async function fetchShoppingList() { | ||||
|       const data = await shoppingListItemActions.getList(); | ||||
|       return data; | ||||
|     } | ||||
|  | ||||
|     async function refresh() { | ||||
|       loadingCounter.value += 1; | ||||
|       try { | ||||
|         await shoppingListItemActions.process(); | ||||
|       } | ||||
|       catch (error) { | ||||
|         console.error(error); | ||||
|       } | ||||
|  | ||||
|       let newListValue: typeof shoppingList.value = null; | ||||
|       try { | ||||
|         newListValue = await fetchShoppingList(); | ||||
|       } | ||||
|       catch (error) { | ||||
|         console.error(error); | ||||
|       } | ||||
|  | ||||
|       loadingCounter.value -= 1; | ||||
|  | ||||
|       // only update the list with the new value if we're not loading, to prevent UI jitter | ||||
|       if (loadingCounter.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Prevent overwriting local changes with stale backend data when offline | ||||
|       if (isOffline.value) { | ||||
|         // Do not update shoppingList.value from backend when offline | ||||
|         updateListItemOrder(); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // if we're not connected to the network, this will be null, so we don't want to clear the list | ||||
|       if (newListValue) { | ||||
|         shoppingList.value = newListValue; | ||||
|       } | ||||
|  | ||||
|       updateListItemOrder(); | ||||
|     } | ||||
|  | ||||
|     function updateListItemOrder() { | ||||
|       if (!preserveItemOrder.value) { | ||||
|         groupAndSortListItemsByFood(); | ||||
|       } | ||||
|       else { | ||||
|         sortListItems(); | ||||
|       } | ||||
|       updateItemsByLabel(); | ||||
|     } | ||||
|  | ||||
|     // constantly polls for changes | ||||
|     async function pollForChanges() { | ||||
|       // pause polling if the user isn't active or we're busy | ||||
|       if (idle.value || loadingCounter.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       try { | ||||
|         await refresh(); | ||||
|  | ||||
|         if (shoppingList.value) { | ||||
|           attempts = 0; | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // if the refresh was unsuccessful, the shopping list will be null, so we increment the attempt counter | ||||
|         attempts++; | ||||
|       } | ||||
|  | ||||
|       catch { | ||||
|         attempts++; | ||||
|       } | ||||
|  | ||||
|       // if we hit too many errors, stop polling | ||||
|       if (attempts >= maxAttempts) { | ||||
|         clearInterval(pollTimer); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // start polling | ||||
|     loadingCounter.value -= 1; | ||||
|     pollForChanges(); // populate initial list | ||||
|  | ||||
|     // max poll time = pollFrequency * maxAttempts = 24 hours | ||||
|     // we use a long max poll time since polling stops when the user is idle anyway | ||||
|     const pollFrequency = 5000; | ||||
|     const maxAttempts = 17280; | ||||
|     let attempts = 0; | ||||
|  | ||||
|     const pollTimer: ReturnType<typeof setInterval> = setInterval(() => { | ||||
|       pollForChanges(); | ||||
|     }, pollFrequency); | ||||
|     onUnmounted(() => { | ||||
|       clearInterval(pollTimer); | ||||
|     }); | ||||
|  | ||||
|     // ===================================== | ||||
|     // List Item CRUD | ||||
|  | ||||
|     // Hydrate listItems from shoppingList.value?.listItems | ||||
|     const listItems = reactive({ | ||||
|       unchecked: [] as ShoppingListItemOut[], | ||||
|       checked: [] as ShoppingListItemOut[], | ||||
|     }); | ||||
|  | ||||
|     function sortCheckedItems(a: ShoppingListItemOut, b: ShoppingListItemOut) { | ||||
|       if (a.updatedAt! === b.updatedAt!) { | ||||
|         return ((a.position || 0) > (b.position || 0)) ? -1 : 1; | ||||
|       } | ||||
|       return a.updatedAt! < b.updatedAt! ? 1 : -1; | ||||
|     } | ||||
|  | ||||
|     watch( | ||||
|       () => shoppingList.value?.listItems, | ||||
|       (items) => { | ||||
|         listItems.unchecked = (items?.filter(item => !item.checked) ?? []); | ||||
|         listItems.checked = (items?.filter(item => item.checked) | ||||
|           .sort(sortCheckedItems) ?? []); | ||||
|       }, | ||||
|       { immediate: true }, | ||||
|     ); | ||||
|  | ||||
|     // ===================================== | ||||
|     // Collapsable Labels | ||||
|     const labelOpenState = ref<{ [key: string]: boolean }>({}); | ||||
|  | ||||
|     const initializeLabelOpenStates = () => { | ||||
|       if (!shoppingList.value?.listItems) return; | ||||
|  | ||||
|       const existingLabels = new Set(Object.keys(labelOpenState.value)); | ||||
|       let hasChanges = false; | ||||
|  | ||||
|       for (const item of shoppingList.value.listItems) { | ||||
|         const labelName = item.label?.name || i18n.t("shopping-list.no-label"); | ||||
|         if (!existingLabels.has(labelName) && !(labelName in labelOpenState.value)) { | ||||
|           labelOpenState.value[labelName] = true; | ||||
|           hasChanges = true; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       if (hasChanges) { | ||||
|         labelOpenState.value = { ...labelOpenState.value }; | ||||
|       } | ||||
|     }; | ||||
|  | ||||
|     const labelNames = computed(() => { | ||||
|       return new Set( | ||||
|         shoppingList.value?.listItems | ||||
|           ?.map(item => item.label?.name || i18n.t("shopping-list.no-label")) | ||||
|           .filter(Boolean) ?? [], | ||||
|       ); | ||||
|     }); | ||||
|  | ||||
|     watch(labelNames, initializeLabelOpenStates, { immediate: true }); | ||||
|  | ||||
|     function toggleShowLabel(key: string) { | ||||
|       labelOpenState.value[key] = !labelOpenState.value[key]; | ||||
|     } | ||||
|  | ||||
|     const [showChecked, toggleShowChecked] = useToggle(false); | ||||
|  | ||||
|     // ===================================== | ||||
|     // Copy List Items | ||||
|  | ||||
|     const copy = useCopyList(); | ||||
|  | ||||
|     function copyListItems(copyType: CopyTypes) { | ||||
|       const text: string[] = []; | ||||
|  | ||||
|       if (preferences.value.viewByLabel) { | ||||
|         // if we're sorting by label, we want the copied text in subsections | ||||
|         Object.entries(itemsByLabel.value).forEach(([label, items], idx) => { | ||||
|           // for every group except the first, add a blank line | ||||
|           if (idx) { | ||||
|             text.push(""); | ||||
|           } | ||||
|  | ||||
|           // add an appropriate heading for the label depending on the copy format | ||||
|           text.push(formatCopiedLabelHeading(copyType, label)); | ||||
|  | ||||
|           // now add the appropriately formatted list items with the given label | ||||
|           items.forEach(item => text.push(formatCopiedListItem(copyType, item))); | ||||
|         }); | ||||
|       } | ||||
|       else { | ||||
|         // labels are toggled off, so just copy in the order they come in | ||||
|         const items = shoppingList.value?.listItems?.filter(item => !item.checked); | ||||
|  | ||||
|         items?.forEach((item) => { | ||||
|           text.push(formatCopiedListItem(copyType, item)); | ||||
|         }); | ||||
|       } | ||||
|  | ||||
|       copy.copyPlain(text); | ||||
|     } | ||||
|  | ||||
|     function formatCopiedListItem(copyType: CopyTypes, item: ShoppingListItemOut): string { | ||||
|       const display = item.display || ""; | ||||
|       switch (copyType) { | ||||
|         case "markdown": | ||||
|           return `- [ ] ${display}`; | ||||
|         default: | ||||
|           return display; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function formatCopiedLabelHeading(copyType: CopyTypes, label: string): string { | ||||
|       switch (copyType) { | ||||
|         case "markdown": | ||||
|           return `# ${label}`; | ||||
|         default: | ||||
|           return `[${label}]`; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // Check / Uncheck All | ||||
|     function openCheckAll() { | ||||
|       if (shoppingList.value?.listItems?.some(item => !item.checked)) { | ||||
|         state.checkAllDialog = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function checkAll() { | ||||
|       state.checkAllDialog = false; | ||||
|       let hasChanged = false; | ||||
|       shoppingList.value?.listItems?.forEach((item) => { | ||||
|         if (!item.checked) { | ||||
|           hasChanged = true; | ||||
|           item.checked = true; | ||||
|         } | ||||
|       }); | ||||
|       if (hasChanged) { | ||||
|         updateUncheckedListItems(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function openUncheckAll() { | ||||
|       if (shoppingList.value?.listItems?.some(item => item.checked)) { | ||||
|         state.uncheckAllDialog = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function uncheckAll() { | ||||
|       state.uncheckAllDialog = false; | ||||
|       let hasChanged = false; | ||||
|       shoppingList.value?.listItems?.forEach((item) => { | ||||
|         if (item.checked) { | ||||
|           hasChanged = true; | ||||
|           item.checked = false; | ||||
|         } | ||||
|       }); | ||||
|       if (hasChanged) { | ||||
|         listItems.unchecked = [...listItems.unchecked, ...listItems.checked]; | ||||
|         listItems.checked = []; | ||||
|         updateUncheckedListItems(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function openDeleteChecked() { | ||||
|       if (shoppingList.value?.listItems?.some(item => item.checked)) { | ||||
|         state.deleteCheckedDialog = true; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     function deleteChecked() { | ||||
|       const checked = shoppingList.value?.listItems?.filter(item => item.checked); | ||||
|  | ||||
|       if (!checked || checked?.length === 0) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       loadingCounter.value += 1; | ||||
|       deleteListItems(checked); | ||||
|  | ||||
|       loadingCounter.value -= 1; | ||||
|       refresh(); | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // List Item Context Menu | ||||
|  | ||||
|     const contextActions = { | ||||
|       delete: "delete", | ||||
|     }; | ||||
|  | ||||
|     const contextMenu = [ | ||||
|       { title: i18n.t("general.delete"), action: contextActions.delete }, | ||||
|     ]; | ||||
|  | ||||
|     // ===================================== | ||||
|     // Labels, Units, Foods | ||||
|     // TODO: Extract to Composable | ||||
|  | ||||
|     const localLabels = ref<ShoppingListMultiPurposeLabelOut[]>(); | ||||
|  | ||||
|     const shoppingListPage = useShoppingListPage(id); | ||||
|     const { store: allLabels } = useLabelStore(); | ||||
|     const { store: allUnits } = useUnitStore(); | ||||
|     const { store: allFoods } = useFoodStore(); | ||||
|  | ||||
|     function getLabelColor(item: ShoppingListItemOut | null) { | ||||
|       return item?.label?.color; | ||||
|     } | ||||
|  | ||||
|     function sortByLabels() { | ||||
|       preferences.value.viewByLabel = !preferences.value.viewByLabel; | ||||
|     } | ||||
|  | ||||
|     function toggleReorderLabelsDialog() { | ||||
|       // stop polling and populate localLabels | ||||
|       loadingCounter.value += 1; | ||||
|       reorderLabelsDialog.value = !reorderLabelsDialog.value; | ||||
|       localLabels.value = shoppingList.value?.labelSettings; | ||||
|     } | ||||
|  | ||||
|     function updateLabelOrder(labelSettings: ShoppingListMultiPurposeLabelOut[]) { | ||||
|       if (!shoppingList.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       labelSettings.forEach((labelSetting, index) => { | ||||
|         labelSetting.position = index; | ||||
|         return labelSetting; | ||||
|       }); | ||||
|  | ||||
|       localLabels.value = labelSettings; | ||||
|     } | ||||
|  | ||||
|     function cancelLabelOrder() { | ||||
|       loadingCounter.value -= 1; | ||||
|       if (!shoppingList.value) { | ||||
|         return; | ||||
|       } | ||||
|       // restore original state | ||||
|       localLabels.value = shoppingList.value.labelSettings; | ||||
|     } | ||||
|  | ||||
|     async function saveLabelOrder() { | ||||
|       if (!shoppingList.value || !localLabels.value || (localLabels.value === shoppingList.value.labelSettings)) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       loadingCounter.value += 1; | ||||
|       const { data } = await userApi.shopping.lists.updateLabelSettings(shoppingList.value.id, localLabels.value); | ||||
|       loadingCounter.value -= 1; | ||||
|  | ||||
|       if (data) { | ||||
|         // update shoppingList labels using the API response | ||||
|         shoppingList.value.labelSettings = (data as ShoppingListOut).labelSettings; | ||||
|         updateItemsByLabel(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const presentLabels = computed(() => { | ||||
|       const labels: PresentLabel[] = []; | ||||
|  | ||||
|       shoppingList.value?.listItems?.forEach((item) => { | ||||
|         if (item.labelId && item.label) { | ||||
|           labels.push({ | ||||
|             name: item.label.name, | ||||
|             id: item.labelId, | ||||
|           }); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       return labels; | ||||
|     }); | ||||
|  | ||||
|     const itemsByLabel = ref<{ [key: string]: ShoppingListItemOut[] }>({}); | ||||
|  | ||||
|     interface ListItemGroup { | ||||
|       position: number; | ||||
|       createdAt: string; | ||||
|       items: ShoppingListItemOut[]; | ||||
|     } | ||||
|  | ||||
|     function sortItems(a: ShoppingListItemOut | ListItemGroup, b: ShoppingListItemOut | ListItemGroup) { | ||||
|       // Sort by position ASC, then by createdAt ASC | ||||
|       const posA = a.position ?? 0; | ||||
|       const posB = b.position ?? 0; | ||||
|       if (posA !== posB) { | ||||
|         return posA - posB; | ||||
|       } | ||||
|       const createdA = a.createdAt ?? ""; | ||||
|       const createdB = b.createdAt ?? ""; | ||||
|       if (createdA !== createdB) { | ||||
|         return createdA < createdB ? -1 : 1; | ||||
|       } | ||||
|       return 0; | ||||
|     } | ||||
|  | ||||
|     function groupAndSortListItemsByFood() { | ||||
|       if (!shoppingList.value?.listItems?.length) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const checkedItemKey = "__checkedItem"; | ||||
|       const listItemGroupsMap = new Map<string, ListItemGroup>(); | ||||
|       listItemGroupsMap.set(checkedItemKey, { position: Number.MAX_SAFE_INTEGER, createdAt: "", items: [] }); | ||||
|  | ||||
|       // group items by checked status, food, or note | ||||
|       shoppingList.value.listItems.forEach((item) => { | ||||
|         const key = item.checked | ||||
|           ? checkedItemKey | ||||
|           : item.food?.name | ||||
|             ? item.food.name | ||||
|             : item.note || ""; | ||||
|  | ||||
|         const group = listItemGroupsMap.get(key); | ||||
|         if (!group) { | ||||
|           listItemGroupsMap.set(key, { position: item.position || 0, createdAt: item.createdAt || "", items: [item] }); | ||||
|         } | ||||
|         else { | ||||
|           group.items.push(item); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       const listItemGroups = Array.from(listItemGroupsMap.values()); | ||||
|       listItemGroups.sort(sortItems); | ||||
|  | ||||
|       // sort group items, then aggregate them | ||||
|       const sortedItems: ShoppingListItemOut[] = []; | ||||
|       let nextPosition = 0; | ||||
|       listItemGroups.forEach((listItemGroup) => { | ||||
|         listItemGroup.items.sort(sortItems); | ||||
|         listItemGroup.items.forEach((item) => { | ||||
|           item.position = nextPosition; | ||||
|           nextPosition += 1; | ||||
|           sortedItems.push(item); | ||||
|         }); | ||||
|       }); | ||||
|  | ||||
|       shoppingList.value.listItems = sortedItems; | ||||
|     } | ||||
|  | ||||
|     function sortListItems() { | ||||
|       if (!shoppingList.value?.listItems?.length) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       shoppingList.value.listItems.sort(sortItems); | ||||
|     } | ||||
|  | ||||
|     function updateItemsByLabel() { | ||||
|       const items: { [prop: string]: ShoppingListItemOut[] } = {}; | ||||
|       const noLabelText = i18n.t("shopping-list.no-label"); | ||||
|       const noLabel = [] as ShoppingListItemOut[]; | ||||
|  | ||||
|       shoppingList.value?.listItems?.forEach((item) => { | ||||
|         if (item.checked) { | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         if (item.labelId) { | ||||
|           if (item.label && item.label.name in items) { | ||||
|             items[item.label.name].push(item); | ||||
|           } | ||||
|           else if (item.label) { | ||||
|             items[item.label.name] = [item]; | ||||
|           } | ||||
|         } | ||||
|         else { | ||||
|           noLabel.push(item); | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       if (noLabel.length > 0) { | ||||
|         items[noLabelText] = noLabel; | ||||
|       } | ||||
|  | ||||
|       // sort the map by label order | ||||
|       const orderedLabelNames = shoppingList.value?.labelSettings?.map(labelSetting => labelSetting.label.name); | ||||
|       if (!orderedLabelNames) { | ||||
|         itemsByLabel.value = items; | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       const itemsSorted: { [prop: string]: ShoppingListItemOut[] } = {}; | ||||
|       if (noLabelText in items) { | ||||
|         itemsSorted[noLabelText] = items[noLabelText]; | ||||
|       } | ||||
|  | ||||
|       orderedLabelNames.forEach((labelName) => { | ||||
|         if (labelName in items) { | ||||
|           itemsSorted[labelName] = items[labelName]; | ||||
|         } | ||||
|       }); | ||||
|  | ||||
|       itemsByLabel.value = itemsSorted; | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // Add/Remove Recipe References | ||||
|  | ||||
|     const recipeMap = computed(() => new Map( | ||||
|       (shoppingList.value?.recipeReferences?.map(ref => ref.recipe) ?? []) | ||||
|         .map(recipe => [recipe.id || "", recipe])), | ||||
|     ); | ||||
|  | ||||
|     async function addRecipeReferenceToList(recipeId: string) { | ||||
|       if (!shoppingList.value || recipeReferenceLoading.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       loadingCounter.value += 1; | ||||
|       recipeReferenceLoading.value = true; | ||||
|       const { data } = await userApi.shopping.lists.addRecipes(shoppingList.value.id, [{ recipeId }]); | ||||
|       recipeReferenceLoading.value = false; | ||||
|       loadingCounter.value -= 1; | ||||
|  | ||||
|       if (data) { | ||||
|         refresh(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     async function removeRecipeReferenceToList(recipeId: string) { | ||||
|       if (!shoppingList.value || recipeReferenceLoading.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       loadingCounter.value += 1; | ||||
|       recipeReferenceLoading.value = true; | ||||
|       const { data } = await userApi.shopping.lists.removeRecipe(shoppingList.value.id, recipeId); | ||||
|       recipeReferenceLoading.value = false; | ||||
|       loadingCounter.value -= 1; | ||||
|  | ||||
|       if (data) { | ||||
|         refresh(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // List Item CRUD | ||||
|  | ||||
|     /* | ||||
|      * saveListItem updates and update on the backend server. Additionally, if the item is | ||||
|      * checked it will also append that item to the end of the list so that the unchecked items | ||||
|      * are at the top of the list. | ||||
|      */ | ||||
|     function saveListItem(item: ShoppingListItemOut) { | ||||
|       if (!shoppingList.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // set a temporary updatedAt timestamp prior to refresh so it appears at the top of the checked items | ||||
|       item.updatedAt = new Date().toISOString(); | ||||
|  | ||||
|       // make updates reflect immediately | ||||
|       if (shoppingList.value.listItems) { | ||||
|         shoppingList.value.listItems.forEach((oldListItem: ShoppingListItemOut, idx: number) => { | ||||
|           if (oldListItem.id === item.id && shoppingList.value?.listItems) { | ||||
|             shoppingList.value.listItems[idx] = item; | ||||
|           } | ||||
|         }); | ||||
|         // Immediately update checked/unchecked arrays for UI | ||||
|         listItems.unchecked = shoppingList.value.listItems.filter(i => !i.checked); | ||||
|         listItems.checked = shoppingList.value.listItems.filter(i => i.checked) | ||||
|           .sort(sortCheckedItems); | ||||
|       } | ||||
|  | ||||
|       // Update the item if it's checked, otherwise updateUncheckedListItems will handle it | ||||
|       if (item.checked) { | ||||
|         shoppingListItemActions.updateItem(item); | ||||
|       } | ||||
|  | ||||
|       updateListItemOrder(); | ||||
|       updateUncheckedListItems(); | ||||
|     } | ||||
|  | ||||
|     function deleteListItem(item: ShoppingListItemOut) { | ||||
|       if (!shoppingList.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       shoppingListItemActions.deleteItem(item); | ||||
|  | ||||
|       // remove the item from the list immediately so the user sees the change | ||||
|       if (shoppingList.value.listItems) { | ||||
|         shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => itm.id !== item.id); | ||||
|       } | ||||
|  | ||||
|       refresh(); | ||||
|     } | ||||
|  | ||||
|     // ===================================== | ||||
|     // Create New Item | ||||
|  | ||||
|     const createEditorOpen = ref(false); | ||||
|     const createListItemData = ref<ShoppingListItemOut>(listItemFactory()); | ||||
|  | ||||
|     function listItemFactory(): ShoppingListItemOut { | ||||
|     return { | ||||
|         id: uuid4(), | ||||
|         shoppingListId: id, | ||||
|         checked: false, | ||||
|         position: shoppingList.value?.listItems?.length || 1, | ||||
|         quantity: 0, | ||||
|         note: "", | ||||
|         labelId: undefined, | ||||
|         unitId: undefined, | ||||
|         foodId: undefined, | ||||
|       } as ShoppingListItemOut; | ||||
|     } | ||||
|  | ||||
|     /* const newMeal = reactive({ | ||||
|       date: "", | ||||
|       title: "", | ||||
|       text: "", | ||||
|       recipeId: undefined as string | undefined, | ||||
|       entryType: "dinner" as PlanEntryType, | ||||
|       existing: false, | ||||
|       id: 0, | ||||
|       groupId: "", | ||||
|       userId: $auth.user.value?.id || "", | ||||
|     }); */ | ||||
|  | ||||
|     function createListItem() { | ||||
|       if (!shoppingList.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (!createListItemData.value.foodId && !createListItemData.value.note) { | ||||
|         // don't create an empty item | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       loadingCounter.value += 1; | ||||
|  | ||||
|       // make sure it's inserted into the end of the list, which may have been updated | ||||
|       createListItemData.value.position = shoppingList.value?.listItems?.length | ||||
|         ? (shoppingList.value.listItems.reduce((a, b) => (a.position || 0) > (b.position || 0) ? a : b).position || 0) + 1 | ||||
|         : 0; | ||||
|  | ||||
|       createListItemData.value.createdAt = new Date().toISOString(); | ||||
|       createListItemData.value.updatedAt = createListItemData.value.createdAt; | ||||
|  | ||||
|       updateListItemOrder(); | ||||
|  | ||||
|       shoppingListItemActions.createItem(createListItemData.value); | ||||
|       loadingCounter.value -= 1; | ||||
|  | ||||
|       if (shoppingList.value.listItems) { | ||||
|         // add the item to the list immediately so the user sees the change | ||||
|         shoppingList.value.listItems.push(createListItemData.value); | ||||
|         updateListItemOrder(); | ||||
|       } | ||||
|       createListItemData.value = listItemFactory(); | ||||
|       refresh(); | ||||
|     } | ||||
|  | ||||
|     function updateIndexUnchecked(uncheckedItems: ShoppingListItemOut[]) { | ||||
|       listItems.unchecked = uncheckedItems; | ||||
|       listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || []; | ||||
|  | ||||
|       // since the user has manually reordered the list, we should preserve this order | ||||
|       preserveItemOrder.value = true; | ||||
|  | ||||
|       updateUncheckedListItems(); | ||||
|     } | ||||
|  | ||||
|     function updateIndexUncheckedByLabel(labelName: string, labeledUncheckedItems: ShoppingListItemOut[]) { | ||||
|       if (!itemsByLabel.value[labelName]) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // update this label's item order | ||||
|       itemsByLabel.value[labelName] = labeledUncheckedItems; | ||||
|  | ||||
|       // reset list order of all items | ||||
|       const allUncheckedItems: ShoppingListItemOut[] = []; | ||||
|       for (labelName in itemsByLabel.value) { | ||||
|         allUncheckedItems.push(...itemsByLabel.value[labelName]); | ||||
|       } | ||||
|  | ||||
|       // since the user has manually reordered the list, we should preserve this order | ||||
|       preserveItemOrder.value = true; | ||||
|  | ||||
|       // save changes | ||||
|       listItems.unchecked = allUncheckedItems; | ||||
|       listItems.checked = shoppingList.value?.listItems?.filter(item => item.checked) || []; | ||||
|       updateUncheckedListItems(); | ||||
|     } | ||||
|  | ||||
|     function deleteListItems(items: ShoppingListItemOut[]) { | ||||
|       if (!shoppingList.value) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       items.forEach((item) => { | ||||
|         shoppingListItemActions.deleteItem(item); | ||||
|       }); | ||||
|       // remove the items from the list immediately so the user sees the change | ||||
|       if (shoppingList.value?.listItems) { | ||||
|         const deletedItems = new Set(items.map(item => item.id)); | ||||
|         shoppingList.value.listItems = shoppingList.value.listItems.filter(itm => !deletedItems.has(itm.id)); | ||||
|       } | ||||
|  | ||||
|       refresh(); | ||||
|     } | ||||
|  | ||||
|     function updateUncheckedListItems() { | ||||
|       if (!shoppingList.value?.listItems) { | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Set position for unchecked items | ||||
|       listItems.unchecked.forEach((item: ShoppingListItemOut, idx: number) => { | ||||
|         item.position = idx; | ||||
|         shoppingListItemActions.updateItem(item); | ||||
|       }); | ||||
|  | ||||
|       refresh(); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       ...toRefs(state), | ||||
|       addRecipeReferenceToList, | ||||
|       allLabels, | ||||
|       contextMenu, | ||||
|       copyListItems, | ||||
|       createEditorOpen, | ||||
|       createListItem, | ||||
|       createListItemData, | ||||
|       deleteChecked, | ||||
|       openDeleteChecked, | ||||
|       deleteListItem, | ||||
|       edit, | ||||
|       threeDot, | ||||
|       getLabelColor, | ||||
|       groupSlug, | ||||
|       itemsByLabel, | ||||
|       listItems, | ||||
|       loadingCounter, | ||||
|       preferences, | ||||
|       presentLabels, | ||||
|       recipeMap, | ||||
|       removeRecipeReferenceToList, | ||||
|       reorderLabelsDialog, | ||||
|       toggleReorderLabelsDialog, | ||||
|       localLabels, | ||||
|       updateLabelOrder, | ||||
|       cancelLabelOrder, | ||||
|       saveLabelOrder, | ||||
|       saveListItem, | ||||
|       shoppingList, | ||||
|       showChecked, | ||||
|       sortByLabels, | ||||
|       labelOpenState, | ||||
|       toggleShowLabel, | ||||
|       toggleShowChecked, | ||||
|       uncheckAll, | ||||
|       openUncheckAll, | ||||
|       checkAll, | ||||
|       openCheckAll, | ||||
|       updateIndexUnchecked, | ||||
|       updateIndexUncheckedByLabel, | ||||
|       allLabels, | ||||
|       allUnits, | ||||
|       allFoods, | ||||
|       getTextColor, | ||||
|       isOffline, | ||||
|       mdAndUp, | ||||
|       ...shoppingListPage, | ||||
|     }; | ||||
|   }, | ||||
| }); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user