Skip to content

Commit 43115cf

Browse files
User: Improve Courses/Sessions list loading - refs #3855
1 parent a013a2b commit 43115cf

5 files changed

Lines changed: 312 additions & 160 deletions

File tree

Lines changed: 165 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,25 @@
11
<template>
2-
<StickyCourses />
2+
<!-- Special / sticky courses -->
3+
<div class="mb-8">
4+
<StickyCourses />
5+
<div
6+
aria-hidden="true"
7+
class="mt-8 flex items-center gap-4"
8+
>
9+
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent"></div>
10+
<div class="h-2 w-2 rounded-full bg-gray-300"></div>
11+
<div class="h-px flex-1 bg-gradient-to-r from-transparent via-gray-300 to-transparent"></div>
12+
</div>
13+
</div>
314

15+
<!-- Regular courses -->
416
<div class="relative min-h-[300px]">
5-
<Loading :visible="!isFullyLoaded" />
17+
<!-- Full-screen / page loading only until first page is received -->
18+
<Loading :visible="!isInitialLoaded" />
19+
20+
<!-- Skeleton only for the very first paint -->
621
<div
7-
v-if="isLoading && courses.length === 0"
22+
v-if="loading && courses.length === 0"
823
class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
924
>
1025
<Skeleton height="16rem" />
@@ -22,15 +37,31 @@
2237
/>
2338
</div>
2439

40+
<!-- Courses grid -->
2541
<div
26-
v-if="courses.length > 0"
42+
v-else-if="courses.length > 0"
2743
class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
2844
>
2945
<CourseCardList :courses="courses" />
30-
<div ref="lastCourseRef"></div>
46+
<!-- Footer slot of the grid (spans all columns) -->
47+
<div class="col-span-full">
48+
<!-- Loading more indicator -->
49+
<div
50+
v-if="isLoadingMore"
51+
class="flex items-center justify-center gap-3 py-6 text-gray-600"
52+
>
53+
<span
54+
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-700"
55+
></span>
56+
<span>{{ t("Loading more courses...") }}</span>
57+
</div>
58+
<!-- Sentinel (keep it always there so it can be observed) -->
59+
<div ref="lastCourseRef"></div>
60+
</div>
3161
</div>
62+
<!-- Empty state -->
3263
<EmptyState
33-
v-else-if="!isLoading && courses.length === 0"
64+
v-else-if="!loading && isInitialLoaded && courses.length === 0"
3465
:detail="t('Go to Explore to find a topic of interest, or wait for someone to subscribe you')"
3566
:summary="t('You don\'t have any course yet.')"
3667
icon="courses"
@@ -39,11 +70,10 @@
3970
</template>
4071

4172
<script setup>
42-
import { nextTick, onMounted, ref, watch } from "vue"
73+
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
4374
import { useQuery } from "@vue/apollo-composable"
4475
import { useI18n } from "vue-i18n"
4576
import { GET_COURSE_REL_USER } from "../../../graphql/queries/CourseRelUser.js"
46-
import Skeleton from "primevue/skeleton"
4777
import StickyCourses from "../../../views/user/courses/StickyCourses.vue"
4878
import CourseCardList from "../../../components/course/CourseCardList.vue"
4979
import EmptyState from "../../../components/EmptyState"
@@ -54,116 +84,158 @@ const securityStore = useSecurityStore()
5484
const { t } = useI18n()
5585
5686
const courses = ref([])
57-
const isLoading = ref(false)
58-
const isFullyLoaded = ref(false)
87+
const isLoadingMore = ref(false) // only for fetchMore
88+
const isInitialLoaded = ref(false) // first page received
5989
const endCursor = ref(null)
6090
const hasMore = ref(true)
6191
const lastCourseRef = ref(null)
6292
63-
const { result, fetchMore } = useQuery(GET_COURSE_REL_USER, {
64-
user: securityStore.user["@id"],
65-
first: 30,
66-
after: null,
67-
})
93+
// Fast dedupe
94+
const courseIds = new Set()
6895
69-
const sortCourses = () => {
70-
courses.value.sort((a, b) => a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: "base" }))
71-
}
96+
// Faster sorting (and more correct with numbers)
97+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" })
98+
let sortScheduled = false
99+
const scheduleSort = () => {
100+
if (sortScheduled) return
101+
sortScheduled = true
72102
73-
watch(result, (newResult) => {
74-
if (newResult?.courseRelUsers) {
75-
const newCourses = newResult.courseRelUsers.edges.map(({ node }) => node.course)
103+
const run = () => {
104+
sortScheduled = false
105+
courses.value.sort((a, b) => collator.compare(a?.title || "", b?.title || ""))
106+
}
76107
77-
const filteredCourses = newCourses.filter(
78-
(newCourse) => !courses.value.some((existingCourse) => existingCourse._id === newCourse._id),
79-
)
108+
// Do not block UI thread if possible
109+
if (typeof window !== "undefined" && "requestIdleCallback" in window) {
110+
window.requestIdleCallback(run, { timeout: 200 })
111+
} else {
112+
setTimeout(run, 0)
113+
}
114+
}
80115
81-
if (filteredCourses.length > 0) {
82-
courses.value.push(...filteredCourses)
83-
sortCourses()
84-
}
116+
const mergeCoursesFromEdges = (edges) => {
117+
let added = 0
85118
86-
endCursor.value = newResult.courseRelUsers.pageInfo.endCursor
87-
hasMore.value = newResult.courseRelUsers.pageInfo.hasNextPage
119+
for (const { node } of edges) {
120+
const c = node?.course
121+
if (!c) continue
88122
89-
nextTick(() => {
90-
if (lastCourseRef.value) {
91-
observer.unobserve(lastCourseRef.value)
92-
observer.observe(lastCourseRef.value)
93-
}
94-
})
123+
const key = c._id ?? c.id
124+
if (!key) continue
125+
126+
if (!courseIds.has(key)) {
127+
courseIds.add(key)
128+
courses.value.push(c)
129+
added++
130+
}
95131
}
96-
isLoading.value = false
97-
isFullyLoaded.value = true
98-
})
99132
100-
const loadMoreCourses = () => {
101-
if (!hasMore.value || isLoading.value) return
102-
isLoading.value = true
103-
104-
setTimeout(() => {
105-
fetchMore({
106-
variables: {
107-
user: securityStore.user["@id"],
108-
first: 10,
109-
after: endCursor.value,
110-
},
111-
updateQuery: (previousResult, { fetchMoreResult }) => {
112-
if (!fetchMoreResult) return previousResult
113-
114-
const newCourses = fetchMoreResult.courseRelUsers.edges.map(({ node }) => node.course)
115-
const filteredCourses = newCourses.filter(
116-
(newCourse) => !courses.value.some((existingCourse) => existingCourse._id === newCourse._id),
117-
)
118-
courses.value.push(...filteredCourses)
119-
sortCourses(courses.value)
120-
endCursor.value = fetchMoreResult.courseRelUsers.pageInfo.endCursor
121-
hasMore.value = fetchMoreResult.courseRelUsers.pageInfo.hasNextPage
122-
123-
return {
124-
...previousResult,
125-
courseRelUsers: {
126-
...fetchMoreResult.courseRelUsers,
127-
edges: [...previousResult.courseRelUsers.edges, ...fetchMoreResult.courseRelUsers.edges],
128-
},
129-
}
130-
},
131-
}).finally(() => {
132-
isLoading.value = false
133-
})
134-
}, 300)
133+
if (added > 0) scheduleSort()
135134
}
136135
137-
let observer = new IntersectionObserver(
138-
(entries) => {
139-
if (entries[0].isIntersecting) {
140-
loadMoreCourses()
141-
}
142-
},
136+
const { result, loading, fetchMore } = useQuery(
137+
GET_COURSE_REL_USER,
138+
() => ({
139+
user: securityStore.user["@id"],
140+
first: 20, // smaller first paint (tune: 12/16/20)
141+
after: null,
142+
}),
143143
{
144-
rootMargin: "300px",
144+
fetchPolicy: "cache-and-network",
145+
nextFetchPolicy: "cache-first",
145146
},
146147
)
147148
148-
onMounted(() => {
149-
courses.value = []
150-
endCursor.value = null
151-
hasMore.value = true
152-
isLoading.value = false
153-
isFullyLoaded.value = false
149+
// Observer (infinite scroll)
150+
let observer = null
151+
152+
const ensureObserver = () => {
153+
if (observer) return
154154
155-
if (observer) observer.disconnect()
156155
observer = new IntersectionObserver(
157156
(entries) => {
158-
if (entries[0].isIntersecting) {
157+
if (entries[0]?.isIntersecting) {
159158
loadMoreCourses()
160159
}
161160
},
162-
{
163-
rootMargin: "300px",
164-
},
161+
{ rootMargin: "800px" },
165162
)
163+
}
164+
165+
const observeLast = async () => {
166+
await nextTick()
167+
if (!lastCourseRef.value) return
166168
167-
loadMoreCourses()
169+
ensureObserver()
170+
observer.disconnect()
171+
observer.observe(lastCourseRef.value)
172+
}
173+
174+
const loadMoreCourses = () => {
175+
if (!hasMore.value || isLoadingMore.value) return
176+
if (loading.value) return
177+
if (!endCursor.value) return
178+
179+
isLoadingMore.value = true
180+
181+
fetchMore({
182+
variables: {
183+
user: securityStore.user["@id"],
184+
first: 20,
185+
after: endCursor.value,
186+
},
187+
updateQuery: (previousResult, { fetchMoreResult }) => {
188+
if (!fetchMoreResult?.courseRelUsers) return previousResult
189+
190+
const edges = fetchMoreResult.courseRelUsers.edges || []
191+
mergeCoursesFromEdges(edges)
192+
193+
endCursor.value = fetchMoreResult.courseRelUsers.pageInfo.endCursor
194+
hasMore.value = fetchMoreResult.courseRelUsers.pageInfo.hasNextPage
195+
196+
return {
197+
...previousResult,
198+
courseRelUsers: {
199+
...fetchMoreResult.courseRelUsers,
200+
edges: [...previousResult.courseRelUsers.edges, ...edges],
201+
},
202+
}
203+
},
204+
}).finally(() => {
205+
isLoadingMore.value = false
206+
})
207+
}
208+
209+
watch(result, async (newResult) => {
210+
const cr = newResult?.courseRelUsers
211+
if (!cr) return
212+
213+
// First page merge
214+
mergeCoursesFromEdges(cr.edges || [])
215+
216+
endCursor.value = cr.pageInfo?.endCursor || null
217+
hasMore.value = !!cr.pageInfo?.hasNextPage
218+
219+
if (!isInitialLoaded.value) {
220+
isInitialLoaded.value = true
221+
}
222+
223+
await observeLast()
224+
})
225+
226+
onMounted(() => {
227+
// Reset local state (optional)
228+
courses.value = []
229+
courseIds.clear()
230+
endCursor.value = null
231+
hasMore.value = true
232+
isLoadingMore.value = false
233+
isInitialLoaded.value = false
234+
235+
ensureObserver()
236+
})
237+
238+
onBeforeUnmount(() => {
239+
if (observer) observer.disconnect()
168240
})
169241
</script>

0 commit comments

Comments
 (0)