From 4174cdb4461b1bb3923a6049b890bb4d3e4a7fc3 Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 12 Mar 2023 12:42:07 -0800 Subject: [PATCH] wip: infinite scroll (broken) --- .../Domain/Recipe/RecipeCardSection.vue | 57 +++-- .../Domain/Recipe/RecipeCardSectionv2.vue | 197 ++++++++++++++++++ frontend/composables/use-infinite-scroll.ts | 48 +++++ frontend/pages/index.vue | 137 ++++++++---- 4 files changed, 366 insertions(+), 73 deletions(-) create mode 100644 frontend/components/Domain/Recipe/RecipeCardSectionv2.vue create mode 100644 frontend/composables/use-infinite-scroll.ts diff --git a/frontend/components/Domain/Recipe/RecipeCardSection.vue b/frontend/components/Domain/Recipe/RecipeCardSection.vue index d3e3c49e4c99..078806446d0c 100644 --- a/frontend/components/Domain/Recipe/RecipeCardSection.vue +++ b/frontend/components/Domain/Recipe/RecipeCardSection.vue @@ -124,7 +124,6 @@ import { reactive, ref, toRefs, - useAsync, useContext, useRouter, watch, @@ -132,7 +131,6 @@ import { import { useThrottleFn } from "@vueuse/core"; import RecipeCard from "./RecipeCard.vue"; import RecipeCardMobile from "./RecipeCardMobile.vue"; -import { useAsyncKey } from "~/composables/use-utils"; import { useLazyRecipes } from "~/composables/recipes"; import { Recipe } from "~/lib/api/types/recipe"; import { useUserSortPreferences } from "~/composables/use-users/preferences"; @@ -198,6 +196,7 @@ export default defineComponent({ }); const router = useRouter(); + function navigateRandom() { if (props.recipes.length > 0) { const recipe = props.recipes[Math.floor(Math.random() * props.recipes.length)]; @@ -221,6 +220,7 @@ export default defineComponent({ }); async function fetchRecipes(pageCount = 1) { + console.log("fetching recipes", pageCount); return await fetchMore( page.value, // we double-up the first call to avoid a bug with large screens that render the entire first page without scrolling, preventing additional loading @@ -261,27 +261,26 @@ export default defineComponent({ } ); - const infiniteScroll = useThrottleFn(() => { - useAsync(async () => { - if (!ready.value || !hasMore.value || loading.value) { - return; - } + const infiniteScroll = useThrottleFn(async () => { + console.log("infinite scroll"); + if (!ready.value || !hasMore.value || loading.value) { + return; + } - loading.value = true; - page.value = page.value + 1; + loading.value = true; + page.value = page.value + 1; - const newRecipes = await fetchRecipes(); - if (!newRecipes.length) { - hasMore.value = false; - } else { - context.emit(APPEND_RECIPES_EVENT, newRecipes); - } + const newRecipes = await fetchRecipes(); + if (newRecipes.length <= 0) { + hasMore.value = false; + } else { + context.emit(APPEND_RECIPES_EVENT, newRecipes); + } - loading.value = false; - }, useAsyncKey()); + loading.value = false; }, 500); - function sortRecipes(sortType: string) { + async function sortRecipes(sortType: string) { if (state.sortLoading || loading.value) { return; } @@ -342,21 +341,19 @@ export default defineComponent({ return; } - useAsync(async () => { - // reset pagination - page.value = 1; - hasMore.value = true; + // reset pagination + page.value = 1; + hasMore.value = true; - state.sortLoading = true; - loading.value = true; + state.sortLoading = true; + loading.value = true; - // fetch new recipes - const newRecipes = await fetchRecipes(); - context.emit(REPLACE_RECIPES_EVENT, newRecipes); + // fetch new recipes + const newRecipes = await fetchRecipes(); + context.emit(REPLACE_RECIPES_EVENT, newRecipes); - state.sortLoading = false; - loading.value = false; - }, useAsyncKey()); + state.sortLoading = false; + loading.value = false; } function toggleMobileCards() { diff --git a/frontend/components/Domain/Recipe/RecipeCardSectionv2.vue b/frontend/components/Domain/Recipe/RecipeCardSectionv2.vue new file mode 100644 index 000000000000..144eca9c7518 --- /dev/null +++ b/frontend/components/Domain/Recipe/RecipeCardSectionv2.vue @@ -0,0 +1,197 @@ + + + + + diff --git a/frontend/composables/use-infinite-scroll.ts b/frontend/composables/use-infinite-scroll.ts new file mode 100644 index 000000000000..1c97d4e3eb8f --- /dev/null +++ b/frontend/composables/use-infinite-scroll.ts @@ -0,0 +1,48 @@ +import { ref, Ref } from "@nuxtjs/composition-api" +import { useDebounceFn } from "@vueuse/core" + + +type InfiniteScrollOptions ={ + page: Ref + perPage: Ref + total: Ref + locked?: Ref + data: Ref + callback: () => Promise +} + + + + +export function useInfiniteScroll(opts: InfiniteScrollOptions) { + const loading = ref(false) + + const { data, callback, locked, page, total} = opts + + const onScrollRaw = () => { + console.log("onScroll") + const { length } = data.value + + + // don't run if: + // - already loading + // - there's nothing more to load + if (loading.value || locked?.value || total.value !== -1 && length >= total.value) { + return + } + + // load more items + loading.value = true + page.value = page.value + 1 + callback().then(() => { + loading.value = false + }) + } + + const onScroll = useDebounceFn(onScrollRaw, 100) + + return { + onScroll, + loading, + } +} diff --git a/frontend/pages/index.vue b/frontend/pages/index.vue index 6bb600354280..daf3b8ec1ae8 100644 --- a/frontend/pages/index.vue +++ b/frontend/pages/index.vue @@ -118,14 +118,13 @@ - @@ -136,20 +135,22 @@ import { ref, defineComponent, useRouter, onMounted, useContext, computed } from import { watchDebounced } from "@vueuse/shared"; import SearchFilter from "~/components/Domain/SearchFilter.vue"; import { useCategoryStore, useFoodStore, useTagStore, useToolStore } from "~/composables/store"; -import RecipeCardSection from "~/components/Domain/Recipe/RecipeCardSection.vue"; import { IngredientFood, RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe"; import { NoUndefinedField } from "~/lib/api/types/non-generated"; -import { useLazyRecipes } from "~/composables/recipes"; import { RecipeSearchQuery } from "~/lib/api/user/recipes/recipe"; +import RecipeCardSectionv2 from "~/components/Domain/Recipe/RecipeCardSectionv2.vue"; +import { useInfiniteScroll } from "~/composables/use-infinite-scroll"; +import { useUserApi } from "~/composables/api"; +import { RecipeSummary } from "~/lib/api/types/group"; export default defineComponent({ - components: { SearchFilter, RecipeCardSection }, + components: { SearchFilter, RecipeCardSectionv2 }, setup() { - const { recipes, appendRecipes, assignSorted, removeRecipe, replaceRecipes } = useLazyRecipes(); - const router = useRouter(); const { $globals, i18n } = useContext(); + const recipes = ref([]); + const state = ref({ auto: true, search: "", @@ -163,6 +164,22 @@ export default defineComponent({ requireAllFoods: false, }); + const activeQuery = ref({ + search: "", + orderBy: "created_at", + orderDirection: "desc", + requireAllCategories: false, + requireAllTags: false, + requireAllTools: false, + requireAllFoods: false, + categories: [], + tags: [], + tools: [], + foods: [], + }); + + const pageLocked = ref(false); + const categories = useCategoryStore(); const selectedCategories = ref[]>([]); @@ -175,8 +192,6 @@ export default defineComponent({ const tools = useToolStore(); const selectedTools = ref[]>([]); - const passedQuery = ref(null); - function reset() { state.value.search = ""; state.value.orderBy = "created_at"; @@ -190,6 +205,8 @@ export default defineComponent({ selectedTags.value = []; selectedTools.value = []; + recipes.value = []; + router.push({ query: {}, }); @@ -205,43 +222,80 @@ export default defineComponent({ return array.map((item) => item.id); } + const api = useUserApi(); + + const page = ref(0); + const perPage = ref(10); + const total = ref(-1); + async function search() { - await router.push({ - query: { - categories: toIDArray(selectedCategories.value), - foods: toIDArray(selectedFoods.value), - tags: toIDArray(selectedTags.value), - tools: toIDArray(selectedTools.value), + if (pageLocked.value) { + return; + } - // Only add the query param if it's or not default - ...{ - auto: state.value.auto ? undefined : "false", - search: state.value.search === "" ? undefined : state.value.search, - orderBy: state.value.orderBy === "createdAt" ? undefined : state.value.orderBy, - orderDirection: state.value.orderDirection === "desc" ? undefined : state.value.orderDirection, - requireAllCategories: state.value.requireAllCategories ? "true" : undefined, - requireAllTags: state.value.requireAllTags ? "true" : undefined, - requireAllTools: state.value.requireAllTools ? "true" : undefined, - requireAllFoods: state.value.requireAllFoods ? "true" : undefined, - }, - }, - }); + activeQuery.value = { + perPage: perPage.value, + page: page.value, - passedQuery.value = { search: state.value.search, - categories: toIDArray(selectedCategories.value), - foods: toIDArray(selectedFoods.value), - tags: toIDArray(selectedTags.value), - tools: toIDArray(selectedTools.value), + orderBy: state.value.orderBy, + orderDirection: state.value.orderDirection, requireAllCategories: state.value.requireAllCategories, requireAllTags: state.value.requireAllTags, requireAllTools: state.value.requireAllTools, requireAllFoods: state.value.requireAllFoods, - orderBy: state.value.orderBy, - orderDirection: state.value.orderDirection, + categories: toIDArray(selectedCategories.value), + tags: toIDArray(selectedTags.value), + tools: toIDArray(selectedTools.value), + foods: toIDArray(selectedFoods.value), }; + + await router.push({ + query: { + categories: activeQuery.value.categories, + foods: activeQuery.value.foods, + tags: activeQuery.value.tags, + tools: activeQuery.value.tools, + + // Only add the query param if it's or not default + ...{ + auto: state.value.auto ? undefined : "false", + search: activeQuery.value.search === "" ? undefined : activeQuery.value.search, + orderBy: activeQuery.value.orderBy === "createdAt" ? undefined : activeQuery.value.orderBy, + orderDirection: activeQuery.value.orderDirection === "desc" ? undefined : activeQuery.value.orderDirection, + requireAllCategories: activeQuery.value.requireAllCategories ? "true" : undefined, + requireAllTags: activeQuery.value.requireAllTags ? "true" : undefined, + requireAllTools: activeQuery.value.requireAllTools ? "true" : undefined, + requireAllFoods: activeQuery.value.requireAllFoods ? "true" : undefined, + }, + }, + }); + + const result = await api.recipes.search(activeQuery.value); + + if (result.error || result.data == null) { + return; + } + + // append + if (page.value > 0) { + recipes.value = recipes.value.concat(result.data.items); + } else { + recipes.value = result.data.items; + } + total.value = result.data.total; } + const { onScroll } = useInfiniteScroll({ + page, + total, + perPage, + data: recipes, + callback: async () => { + await search(); + }, + }); + function waitUntilAndExecute( condition: () => boolean, callback: () => void, @@ -387,7 +441,8 @@ export default defineComponent({ } Promise.allSettled(promises).then(() => { - search(); + pageLocked.value = false; + onScroll(); }); }); @@ -427,17 +482,13 @@ export default defineComponent({ sortable, toggleOrderDirection, + onScroll, selectedCategories, selectedFoods, selectedTags, selectedTools, - appendRecipes, - assignSorted, recipes, - removeRecipe, - replaceRecipes, - passedQuery, }; }, });