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" />
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"
3970</template >
4071
4172<script setup>
42- import { nextTick , onMounted , ref , watch } from " vue"
73+ import { nextTick , onBeforeUnmount , onMounted , ref , watch } from " vue"
4374import { useQuery } from " @vue/apollo-composable"
4475import { useI18n } from " vue-i18n"
4576import { GET_COURSE_REL_USER } from " ../../../graphql/queries/CourseRelUser.js"
46- import Skeleton from " primevue/skeleton"
4777import StickyCourses from " ../../../views/user/courses/StickyCourses.vue"
4878import CourseCardList from " ../../../components/course/CourseCardList.vue"
4979import EmptyState from " ../../../components/EmptyState"
@@ -54,116 +84,158 @@ const securityStore = useSecurityStore()
5484const { t } = useI18n ()
5585
5686const 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
5989const endCursor = ref (null )
6090const hasMore = ref (true )
6191const 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