Fix page refresh bug in library and search

This commit is contained in:
mustafa-rezae 2025-05-08 14:35:01 +03:30
parent dc2d5cc460
commit 917824f098
4 changed files with 298 additions and 174 deletions

View File

@ -31,7 +31,6 @@ const goToLibraryShow = (item) => {
<template>
<UCard
v-if="props.list?.length"
v-for="(item, index) in props.list"
class="mx-auto"
:key="index"
@ -46,7 +45,6 @@ const goToLibraryShow = (item) => {
<!-- <template #header></template> -->
<ULink
v-if="item?._source?.id"
:to="{
name: 'hadithaLibraryShow',
params: {
@ -65,25 +63,17 @@ const goToLibraryShow = (item) => {
quality="80"
placeholder
src="/img/haditha/library/totally.webp"
/>
<!-- src="/img/haditha/sample-bgi.svg" -->
/>
<!-- src="/img/haditha/sample-bgi.svg" -->
<p class="title">{{ item?._source?.title }}</p>
<p class="version">
جلد
جلد
{{ item?._source?.vol_title + item?._source?.vol_num }}
</p>
</ULink>
<!-- <template #footer> </template> -->
</UCard>
<no-data
class="h-full w-full flex flex-col justify-center items-center"
v-else
>
<img fit="auto" quality="80" placeholder :src="props.noDataIcon" />
<p class="no-data-text">{{ props.noDataText }}</p>
</no-data>
</template>
<style scoped>

View File

@ -7,6 +7,9 @@ const props = defineProps({
return [];
},
},
requestStatus: {
default: "pending",
},
total: {
default: 0,
},
@ -84,6 +87,17 @@ const removeFromFavorites = async (item = {}, index = 0) => {
</script>
<template>
<!-- <template v-if="props.requestStatus == 'pending'"> -->
<!-- <div class="flex items-center gap-4 mb-4" v-for="item in props?.list?.length">
<USkeleton class="h-12 w-12 rounded-full" />
<div class="grid gap-2 flex-grow-1">
<USkeleton class="h-4 " />
<USkeleton class="h-4 " />
</div>
</div> -->
<!-- </template> -->
<!-- <template v-else-if="props.requestStatus == 'success' || props.requestStatus == 'idle'"> -->
<div
v-if="props?.list?.length"
class="search-list-item"
@ -155,6 +169,7 @@ const removeFromFavorites = async (item = {}, index = 0) => {
</p> -->
</div>
</div>
<!-- </template> -->
<!-- <UModal
v-model:open="isModalOpen"

View File

@ -2,14 +2,22 @@
import hadithaApi from "@haditha/apis/hadithaApi";
import headLinks from "@haditha/json/haditha/headLinks";
import headMetas from "@haditha/json/haditha/headMetas";
import { useInfiniteScroll } from "@vueuse/core";
// import { useInfiniteScroll } from "@vueuse/core";
const id_token = useCookie("id_token");
const token = id_token.value ?? "GuestAccess";
const config = useRuntimeConfig();
const baseUrl =
config.public.NUXT_PUBLIC_BASE_URL + config.public.NUXT_PUBLIC_API_NAME;
// this enable us to send cookies.
const requestFetch = useRequestFetch();
definePageMeta({
layout: false,
name: "hadithaLibrary",
});
useHead({
name: "hadithaLibrary",
title: `${import.meta.env.VITE_HADITH_PAGE_TITLE} | کتابخانه`,
meta: [
{ name: "description", content: "کاوش با هوش مصنوعی در احادیث اسلامی" },
@ -22,19 +30,21 @@ useHead({
});
// #region refs
const httpService = useNuxtApp()["$http"];
// const httpService = useNuxtApp()["$http"];
// const { $api } = useNuxtApp()
const offset = useState("offset", () => 0);
const total = useState("total", () => 0);
const loading = useState("loading", () => false);
const hasMore = useState("hasMore", () => true);
// const libraryList = useState("libraryList", () => []);
// const loading = useState("loading", () => false);
// const hasMore = useState("hasMore", () => true);
const el = ref(null);
// const el = ref(null);
// #endregion refs
// #region reactive
const state = reactive({
pagination: {
limit: 10,
limit: 15,
page: 1,
pages: 1,
},
@ -42,59 +52,65 @@ const state = reactive({
// #region methods
const getLibraryList = async () => {
let url = repoUrl() + hadithaApi.library.list;
const getLibraryList = () => {
let url = baseUrl + repoUrl() + hadithaApi.library.list;
url = url.replace("@field_collapsed", "normal");
url = url.replace("@offset", offset.value);
url = url.replace("@limit", state.pagination.limit);
url = url.replace("@q", "none");
return await httpService.postRequest(url).then((res) => {
total.value = res.hits?.total?.value ?? 0;
return requestFetch(url, {
method: "POST",
headers: {
Authorization: token,
},
}).then((data) => {
total.value = data.hits?.total?.value ?? 0;
offset.value += state.pagination.limit;
return res;
return data.hits?.hits;
});
};
// Server-side initial load
const { data: loadedItems } = await useAsyncData(
"libraryList",
() => getLibraryList(),
{
transform: (data) => data.hits.hits,
}
// Wrapping with useAsyncDataavoid double data fetching when
// doing server-side rendering (server & client on hydration).
const { data: libraryList } = await useAsyncData("libraryList", () =>
getLibraryList()
);
// Client-side infinite scroll
useInfiniteScroll(
el,
async () => {
if (!hasMore.value || loading.value) return;
const loadMore = async () => {
// const listElm = $event.target;
loading.value = true;
try {
await getLibraryList().then((res) => {
const hits = res?.hits?.hits ?? [];
// if (status.value == "pending") return;
// // window.innerHeight + window.scrollY >= document.body.offsetHeight - 100
// if (listElm.scrollTop + listElm.clientHeight >= listElm.scrollHeight) {
// status.value = "pending";
// mainState.pagination.offset =
// mainState.pagination.offset + mainState.pagination.limit;
if (hits.length > 0) {
// Use spread operator to create new array reference
loadedItems.value = [...loadedItems.value, ...hits];
} else {
hasMore.value = false;
}
});
} catch (error) {
hasMore.value = false;
// console.error("Error loading more items:", error);
// Consider setting hasMore.value = false if you want to stop on error
} finally {
loading.value = false;
}
},
{ distance: 100 }
);
// if (total.value > mainState.pagination.offset) {
// window.clearTimeout(isScrolling.value);
// isScrolling.value = setTimeout(() => {
return await getLibraryList().then((res) => {
const hits = res ?? [];
// Use spread operator to create new array reference
libraryList.value = [...libraryList.value, ...hits];
// status.value = "success";
return res;
});
// }, 300);
// } else {
// toast.add({
// title: "کاربر محترم",
// description: "دیگر رکوردی جهت بارگزاری وجود ندارد.",
// color: "success",
// });
// status.value = "idle";
// }
// } else status.value = "idle";
};
const { isFetching } = useInfiniteScroll(loadMore, "libraryInfiniteScroll");
// #endregion methods
// components declaration
@ -121,15 +137,24 @@ const CardList = defineAsyncComponent(
</div>
<div
ref="el"
class="library-list pl-4 firefox-scrollbar grid grid-cols-2 gap-x-15 gap-y-12 md:grid-cols-3 md:gap-x-28 md:gap-y-12 lg:grid-cols-5 lg:gap-x-28 lg:gap-y-12 mx-6"
ref="libraryInfiniteScroll"
id="libraryInfiniteScroll"
class="library-list pl-4 firefox-scrollbar gap-x-15 gap-y-12 md:grid-cols-3 md:gap-x-28 md:gap-y-12 lg:grid-cols-5 lg:gap-x-28 lg:gap-y-12 mx-6"
:class="{ 'grid grid-cols-2': libraryList?.length }"
>
<!-- Client-side loaded content -->
<card-list
no-data-text="هنوز چیزی ذخیره نکرده‌اید!"
no-data-icon="/img/haditha/no-data.png"
:list="loadedItems"
:list="libraryList"
no-data-text="به زودی لیست کتاب ها بروزرسانی خواهد شد."
no-data-icon=""
></card-list>
<no-data
class="h-full w-full flex flex-col justify-center items-center"
v-if="libraryList?.length == 0"
>
<p class="no-data-text">به زودی لیست کتاب ها بروزرسانی خواهد شد.</p>
</no-data>
</div>
</div>
</div>
@ -169,6 +194,7 @@ const CardList = defineAsyncComponent(
/* padding: 1em 1.3em; */
height: calc(100dvh - 13.5em);
overflow-y: auto;
scroll-behavior: smooth;
}
.no-data-text {
font-family: var(--font);

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useStorage } from "@vueuse/core";
import { useInfiniteScroll } from "@vueuse/core";
// import { useInfiniteScroll } from "@vueuse/core";
import * as z from "zod";
// const myCookie = useCookie("searchPhrase");
@ -10,6 +10,7 @@ import headLinks from "@haditha/json/haditha/headLinks";
import headMetas from "@haditha/json/haditha/headMetas";
import hadithaApi from "@haditha/apis/hadithaApi";
import type { Synonym } from "@haditha/types/hadithType";
const toast = useToast();
// const searchTerm = useState("searchTerm", () => ''); // Tracks the searchTerm
const offset = useState("offset", () => 0); // Tracks the current offset
@ -34,15 +35,11 @@ useHead({
// #region refs
// وقتی از صفحه حدیث با کلیک بر روی دکمه مشابه، وارد صفحه جستجو میشویم
const showPrevSearch = ref(false);
// لیست جستجو در حالت اسکرول
// const loadedItems = ref([]);
// لودینگ
const loading = ref(false);
// const showPrevSearch = ref(false);
// هنگام اسکرول، چک میشود که ایا صفحه بعدی هم وجود دارد یا نه.
const hasMore = ref(true);
// عنصری که برای اسکرول استفاده میشه.
const el = ref(null);
// const el = ref(null);
// پلاگین ارسال درخواست
const httpService = useNuxtApp()["$http"];
// استفاده از روت
@ -142,9 +139,10 @@ const AutoComplationState = reactive({
});
const mainState = reactive({
pagination: {
limit: 10,
limit: 15,
page: 1,
pages: 1,
offset: 0,
},
});
@ -167,7 +165,7 @@ const backgroundImageStyle = computed(() => {
// });
let optimizedImageUrl = "/img/haditha/background.webp";
if (!showNoData.value) {
if (loadedItems.value) {
// optimizedImageUrl = img("/img/haditha/sub-header-bgi.webp", {
// quality: 80,
// });
@ -205,117 +203,150 @@ const exitSimilarMode = () => {
};
// ارسال درخواست
const sendQuery = async (payload = {}) => {
let url = hadithaApi.search.list;
url = url.replace("@index_key", "dhparag");
url = url.replace("@search_type", search_type.value); // normal, and , phrase, vector, synonym
url = url.replace("@type_key", type_key.value); // hadith, hadith_fa, hadith_ar, hadith_shr
url = url.replace("@offset", offset.value);
url = url.replace("@limit", mainState.pagination.limit);
url = url.replace("@listkey", "normal");
url = url.replace("@field_collapsed", "normal");
try {
let url = hadithaApi.search.list;
url = url.replace("@index_key", "dhparag");
url = url.replace("@search_type", search_type.value); // normal, and , phrase, vector, synonym
url = url.replace("@type_key", type_key.value); // hadith, hadith_fa, hadith_ar, hadith_shr
url = url.replace("@offset", offset.value);
url = url.replace("@limit", mainState.pagination.limit);
url = url.replace("@listkey", "normal");
url = url.replace("@field_collapsed", "normal");
// اگر نوع انتخاب شود.
const isTypeSelected =
typeModelValue.value == "arabic" ||
typeModelValue.value == "translations" ||
typeModelValue.value == "descriptions";
// اگر نوع انتخاب شود.
const isTypeSelected =
typeModelValue.value == "arabic" ||
typeModelValue.value == "translations" ||
typeModelValue.value == "descriptions";
if (searchTerm.value.length) {
url = url.replace(
"@q=none",
`q=${isTypeSelected ? "#" + typeModelValueFa.value + " " : ""}${
searchTerm.value
}`
);
if (searchTerm.value.length) {
url = url.replace(
"@q=none",
`q=${isTypeSelected ? "#" + typeModelValueFa.value + " " : ""}${
searchTerm.value
}`
);
// if (route.query.f_aik?.length) url += `&f_aik=${route.query.f_aik}`;
// if (route.query.f_aik?.length) url += `&f_aik=${route.query.f_aik}`;
}
return await httpService
.postRequest(url, payload)
.then((res) => {
total.value = res.hits.total.value ?? 0;
offset.value += mainState.pagination.limit;
// check if search term is not empty
if (searchTerm.value) userSearchHistory.value.add(searchTerm.value); // Add the value to the Set
// close the history dropdown menu
open.value = false;
return res;
})
.catch((err) => {});
} catch (err) {
console.error("API Error:", err.message);
throw err; // Re-throw the error to be handled by useAsyncData
}
return await httpService.postRequest(url, payload).then((res) => {
total.value = res.hits.total.value ?? 0;
offset.value += mainState.pagination.limit;
// check if search term is not empty
if (searchTerm.value) userSearchHistory.value.add(searchTerm.value); // Add the value to the Set
// close the history dropdown menu
open.value = false;
return res;
});
};
// Server-side initial load
const { data: loadedItems } = await useAsyncData("search", () => sendQuery(), {
transform: (data) => data.hits.hits,
});
showNoData.value = loadedItems.value?.length == 0;
const onBeforeSendQuery = (from) => {
if (loading.value) return;
loading.value = true;
history?.pushState(
{},
document?.title,
route.path + `?q=${searchTerm.value}`
);
const {
data: loadedItems,
status,
refresh,
clear,
error,
execute,
} = await useAsyncData(
"search",
async () => {
if (searchTerm.value.length) {
return await sendQuery();
} else {
return {
hits: {
hits: undefined,
},
};
}
},
{
transform: (data) => data.hits.hits,
}
);
const onBeforeSendQuery = () => {
if (status.value == "pending") return;
status.value = "pending";
offset.value = 0;
if (searchTerm.value?.length) {
sendQuery().then((res) => {
loadedItems.value = res.hits.hits;
loadedItems.value = res?.hits?.hits ?? [];
showNoData.value = loadedItems.value?.length == 0;
loading.value = false;
status.value = "success";
history?.pushState(
{},
document?.title,
route.path + `?q=${searchTerm.value}`
);
route.query.q = searchTerm.value;
});
} else {
searchTerm.value = "";
loading.value = false;
loadedItems.value = [];
showNoData.value = false;
loading.value = false;
resetForm();
// searchTerm.value = "";
// loading.value = false;
// loadedItems.value = [];
// showNoData.value = false;
// loading.value = false;
}
};
// Client-side infinite scroll
useInfiniteScroll(
el,
async () => {
if (!hasMore.value || loading.value) return;
// useInfiniteScroll(
// el,
// async () => {
// if (!hasMore.value || loading.value) return;
loading.value = true;
try {
// const nextPage = page.value + 1;
await sendQuery().then((res) => {
const hits = res.hits.hits;
// loading.value = true;
// try {
// // const nextPage = page.value + 1;
// await sendQuery().then((res) => {
// const hits = res.hits.hits;
if (hits.length) {
loadedItems.value.push(...hits);
} else {
hasMore.value = false;
}
});
} finally {
loading.value = false;
}
},
{ distance: 100 }
);
// if (hits.length) {
// loadedItems.value.push(...hits);
// } else {
// hasMore.value = false;
// }
// });
// } finally {
// loading.value = false;
// }
// },
// { distance: 100 }
// );
// دکمه جستجو کردن
const onSearchButtonClick = () => {
if (loading.value) return;
loading.value = true;
if (status.value == "pending") return;
status.value = "pending";
sendQuery().then((res) => {
loadedItems.value = res.hits.hits;
showNoData.value = loadedItems.value?.length == 0;
loading.value = false;
status.value = "idle";
});
};
const resetForm = () => {
clear();
searchTerm.value = "";
loadedItems.value = [];
route.query.q = null;
// loadedItems.value = [];
status.value = "idle";
showNoData.value = false;
loading.value = false;
history?.pushState({}, document?.title, route.path);
};
// وقتی کاربر کلیدی فشار میدهد
@ -333,10 +364,10 @@ const resetForm = () => {
// تنظیم نوع جستجو
const setType = (type: string) => {
search_type.value = type;
loadedItems.value = [];
// loadedItems.value = [];
offset.value = 0;
sendQuery().then((res) => {
loadedItems.value = res.hits.hits;
loadedItems.value = res?.hits?.hits ?? [];
showNoData.value = loadedItems.value?.length == 0;
});
};
@ -455,6 +486,60 @@ const onAddNewTitle = (subTitles) => {
showNoData.value = loadedItems.value?.length == 0;
});
};
// Using the Intersection Observer version
const loadMore = async () => {
// const listElm = $event.target;
if (!hasMore.value) return;
// // window.innerHeight + window.scrollY >= document.body.offsetHeight - 100
// if (listElm.scrollTop + listElm.clientHeight >= listElm.scrollHeight) {
// status.value = "pending";
// mainState.pagination.offset =
// mainState.pagination.offset + mainState.pagination.limit;
// if (total.value > mainState.pagination.offset) {
// window.clearTimeout(isScrolling.value);
// isScrolling.value = setTimeout(() => {
return await sendQuery().then((res) => {
const hits = res?.hits?.hits ?? [];
if (hits.length == 0) hasMore.value = false;
else loadedItems.value.push(...hits);
// status.value = "success";
return res;
});
// }, 300);
// } else {
// toast.add({
// title: "کاربر محترم",
// description: "دیگر رکوردی جهت بارگزاری وجود ندارد.",
// color: "success",
// });
// status.value = "idle";
// }
// } else status.value = "idle";
};
const { isFetching } = useInfiniteScroll(loadMore, "searchInfiniteScroll");
// Add the scroll event listener when the component is mounted
// onMounted(() => {
// const targetElement = document.getElementById("search-list");
// if (targetElement) {
// targetElement.addEventListener("scroll", loadMore);
// }
// });
// // Remove the scroll event listener when the component is unmounted
// onUnmounted(() => {
// const targetElement = document.getElementById("search-list");
// if (targetElement) {
// targetElement.removeEventListener("scroll", loadMore);
// }
// });
// #endregion methods
// #region components
@ -508,7 +593,7 @@ const SearchList = defineAsyncComponent(
<UInput
class="w-full focus:placeholder-gray-800"
v-model="searchTerm"
v-model.trim="searchTerm"
v-model:open="open"
v-model:search-term="searchTerm"
placeholder="هوشمند جستجو کنید..."
@ -520,7 +605,7 @@ const SearchList = defineAsyncComponent(
side: 'bottom',
sideOffset: 4,
}"
:loading="loading"
:loading="status == 'pending'"
highlight
highlightOnHover
@focus="open = true"
@ -554,7 +639,7 @@ const SearchList = defineAsyncComponent(
</div>
<div
class="search-filter flex items-center my-3 justify-between"
v-if="!showNoData"
v-if="loadedItems"
>
<div class="flex items-center space-x-2">
<!-- #region معنایی -->
@ -782,15 +867,10 @@ const SearchList = defineAsyncComponent(
</div>
</div>
<div
v-if="showNoData"
v-if="!loadedItems"
class="flex justify-center flex-col items-center mt-10"
>
<img
fit="auto"
quality="80"
placeholder
src="/img/haditha/logo.webp"
/>
<img fit="auto" quality="80" src="/img/haditha/logo.webp" />
<div class="title">
کاوش با
<span class="badge-style mx-1">هوش مصنوعی</span>
@ -798,33 +878,40 @@ const SearchList = defineAsyncComponent(
</div>
</div>
<!-- v-show="!showNoData" -->
<div
v-show="!showNoData"
class="search-box-container pb-0 bg-white flex justify-center"
:class="{ 'pt-0': loadedItems == undefined }"
>
<div class="search-list-contianer">
<div class="total">
<div v-if="loadedItems" class="total">
<span>{{ total }}</span>
نتیجه
</div>
<!-- v-show="!loading" -->
<div ref="el" class="search-list firefox-scrollbar">
<!-- ref="el" -->
<div
ref="searchInfiniteScroll"
id="searchInfiniteScroll"
class="search-list firefox-scrollbar"
:class="{ 'enable-scroll': loadedItems?.length }"
>
<search-list
no-data-text="نتیجه‌ای یافت نشد!"
no-data-icon="/img/haditha/no-data.png"
:total="total"
:list="loadedItems"
:searchTerm="searchTerm"
:requestStatus="status"
></search-list>
<!-- <no-data
<no-data
class="h-full w-full flex flex-col justify-center items-center"
v-if="showNoData"
v-if="loadedItems?.length == 0"
>
<img fit="auto" quality="80" src="/img/haditha/no-data.png" />
<p class="no-data-text">"نتیجهای یافت نشد!</p>
</no-data> -->
</no-data>
</div>
</div>
</div>
@ -876,6 +963,9 @@ const SearchList = defineAsyncComponent(
&.pb-0 {
padding-bottom: 0 !important;
}
&.pt-0 {
padding-top: 0 !important;
}
}
.search-list-contianer {
@ -898,9 +988,12 @@ const SearchList = defineAsyncComponent(
color: #b4c2cf;
}
.search-list {
padding: 1em 1.3em;
height: calc(100dvh - 16em);
overflow-y: auto;
&.enable-scroll {
padding: 1em 1.3em;
height: calc(100dvh - 16em);
overflow-y: auto;
scroll-behavior: smooth;
}
&.hadithaFavorites {
height: calc(100dvh - 8em);