mirror of
				https://github.com/mealie-recipes/mealie.git
				synced 2025-10-31 10:13:32 -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> | <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-sheet | ||||||
|       v-for="recipe, index in recipes" |       v-for="recipe, index in recipes" | ||||||
|       :key="recipe.id" |       :key="recipe.id" | ||||||
| @@ -41,10 +41,10 @@ | |||||||
|           </v-list-item-subtitle> |           </v-list-item-subtitle> | ||||||
|         </div> |         </div> | ||||||
|         <template #append> |         <template #append> | ||||||
|             <slot |           <slot | ||||||
|               :name="'actions-' + recipe.id" |             :name="'actions-' + recipe.id" | ||||||
|               :v-bind="{ item: recipe }" |             :v-bind="{ item: recipe }" | ||||||
|             /> |           /> | ||||||
|         </template> |         </template> | ||||||
|       </v-list-item> |       </v-list-item> | ||||||
|     </v-sheet> |     </v-sheet> | ||||||
|   | |||||||
| @@ -20,6 +20,7 @@ | |||||||
|         <template #activator="{ props }"> |         <template #activator="{ props }"> | ||||||
|           <v-btn |           <v-btn | ||||||
|             size="small" |             size="small" | ||||||
|  |             variant="text" | ||||||
|             class="ml-2 handle" |             class="ml-2 handle" | ||||||
|             icon |             icon | ||||||
|             v-bind="props" |             v-bind="props" | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ | |||||||
|             v-model="listItem.checked" |             v-model="listItem.checked" | ||||||
|             hide-details |             hide-details | ||||||
|             density="compact" |             density="compact" | ||||||
|             class="mt-0" |             class="mt-0 flex-shrink-0" | ||||||
|             color="null" |             color="null" | ||||||
|             @change="$emit('checked', listItem)" |             @change="$emit('checked', listItem)" | ||||||
|           /> |           /> | ||||||
| @@ -27,16 +27,6 @@ | |||||||
|         </div> |         </div> | ||||||
|       </v-col> |       </v-col> | ||||||
|       <v-spacer /> |       <v-spacer /> | ||||||
|       <v-col |  | ||||||
|         v-if="label && showLabel" |  | ||||||
|         cols="3" |  | ||||||
|         class="text-right" |  | ||||||
|       > |  | ||||||
|         <MultiPurposeLabel |  | ||||||
|           :label="label" |  | ||||||
|           size="small" |  | ||||||
|         /> |  | ||||||
|       </v-col> |  | ||||||
|       <v-col |       <v-col | ||||||
|         cols="auto" |         cols="auto" | ||||||
|         class="text-right" |         class="text-right" | ||||||
| @@ -75,27 +65,6 @@ | |||||||
|                 </template> |                 </template> | ||||||
|                 <span>Toggle Recipes</span> |                 <span>Toggle Recipes</span> | ||||||
|               </v-tooltip> |               </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 |               <v-btn | ||||||
|                 size="small" |                 size="small" | ||||||
|                 variant="text" |                 variant="text" | ||||||
| @@ -107,6 +76,17 @@ | |||||||
|                   {{ $globals.icons.edit }} |                   {{ $globals.icons.edit }} | ||||||
|                 </v-icon> |                 </v-icon> | ||||||
|               </v-btn> |               </v-btn> | ||||||
|  |               <v-btn | ||||||
|  |                 size="small" | ||||||
|  |                 variant="text" | ||||||
|  |                 class="handle" | ||||||
|  |                 icon | ||||||
|  |                 v-bind="props" | ||||||
|  |               > | ||||||
|  |                 <v-icon> | ||||||
|  |                   {{ $globals.icons.arrowUpDown }} | ||||||
|  |                 </v-icon> | ||||||
|  |               </v-btn> | ||||||
|             </template> |             </template> | ||||||
|             <v-list density="compact"> |             <v-list density="compact"> | ||||||
|               <v-list-item |               <v-list-item | ||||||
| @@ -177,7 +157,6 @@ | |||||||
| import { useOnline } from "@vueuse/core"; | import { useOnline } from "@vueuse/core"; | ||||||
| import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; | import RecipeIngredientListItem from "../Recipe/RecipeIngredientListItem.vue"; | ||||||
| import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; | import ShoppingListItemEditor from "./ShoppingListItemEditor.vue"; | ||||||
| import MultiPurposeLabel from "./MultiPurposeLabel.vue"; |  | ||||||
| import type { ShoppingListItemOut } from "~/lib/api/types/household"; | import type { ShoppingListItemOut } from "~/lib/api/types/household"; | ||||||
| import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels"; | import type { MultiPurposeLabelOut, MultiPurposeLabelSummary } from "~/lib/api/types/labels"; | ||||||
| import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; | import type { IngredientFood, IngredientUnit, RecipeSummary } from "~/lib/api/types/recipe"; | ||||||
| @@ -189,16 +168,12 @@ interface actions { | |||||||
| } | } | ||||||
|  |  | ||||||
| export default defineNuxtComponent({ | export default defineNuxtComponent({ | ||||||
|   components: { ShoppingListItemEditor, MultiPurposeLabel, RecipeList, RecipeIngredientListItem }, |   components: { ShoppingListItemEditor, RecipeList, RecipeIngredientListItem }, | ||||||
|   props: { |   props: { | ||||||
|     modelValue: { |     modelValue: { | ||||||
|       type: Object as () => ShoppingListItemOut, |       type: Object as () => ShoppingListItemOut, | ||||||
|       required: true, |       required: true, | ||||||
|     }, |     }, | ||||||
|     showLabel: { |  | ||||||
|       type: Boolean, |  | ||||||
|       default: false, |  | ||||||
|     }, |  | ||||||
|     labels: { |     labels: { | ||||||
|       type: Array as () => MultiPurposeLabelOut[], |       type: Array as () => MultiPurposeLabelOut[], | ||||||
|       required: true, |       required: true, | ||||||
| @@ -220,7 +195,7 @@ export default defineNuxtComponent({ | |||||||
|   setup(props, context) { |   setup(props, context) { | ||||||
|     const i18n = useI18n(); |     const i18n = useI18n(); | ||||||
|     const displayRecipeRefs = ref(false); |     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 isOffline = computed(() => useOnline().value === false); | ||||||
|  |  | ||||||
|     const contextMenu: actions[] = [ |     const contextMenu: actions[] = [ | ||||||
| @@ -305,7 +280,7 @@ export default defineNuxtComponent({ | |||||||
|       } |       } | ||||||
|  |  | ||||||
|       listItem.value.recipeReferences.forEach((ref) => { |       listItem.value.recipeReferences.forEach((ref) => { | ||||||
|         const recipe = props.recipes.get(ref.recipeId); |         const recipe = props.recipes?.get(ref.recipeId); | ||||||
|         if (recipe) { |         if (recipe) { | ||||||
|           recipeList.push(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 { | export interface UserShoppingListPreferences { | ||||||
|   viewAllLists: boolean; |   viewAllLists: boolean; | ||||||
|   viewByLabel: boolean; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| export interface UserTimelinePreferences { | export interface UserTimelinePreferences { | ||||||
| @@ -129,7 +128,6 @@ export function useShoppingListPreferences(): Ref<UserShoppingListPreferences> { | |||||||
|     "shopping-list-preferences", |     "shopping-list-preferences", | ||||||
|     { |     { | ||||||
|       viewAllLists: false, |       viewAllLists: false, | ||||||
|       viewByLabel: true, |  | ||||||
|     }, |     }, | ||||||
|     { mergeDefaults: true }, |     { mergeDefaults: true }, | ||||||
|     // we cast to a Ref because by default it will return an optional type ref |     // we cast to a Ref because by default it will return an optional type ref | ||||||
|   | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
		Reference in New Issue
	
	Block a user