@wyxos/vibe 1.6.25 → 1.6.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +287 -254
- package/lib/index.js +1201 -1115
- package/lib/vibe.css +1 -1
- package/package.json +1 -1
- package/src/Masonry.vue +159 -188
- package/src/components/MasonryItem.vue +501 -434
- package/src/components/examples/BasicExample.vue +2 -1
- package/src/components/examples/CustomItemExample.vue +2 -1
- package/src/components/examples/HeaderFooterExample.vue +79 -78
- package/src/components/examples/ManualInitExample.vue +78 -0
- package/src/components/examples/SwipeModeExample.vue +2 -1
- package/src/{useMasonryTransitions.ts → createMasonryTransitions.ts} +6 -6
- package/src/useMasonryItems.ts +234 -218
- package/src/useMasonryLayout.ts +4 -0
- package/src/useMasonryPagination.ts +465 -342
- package/src/views/Examples.vue +80 -32
- package/src/views/Home.vue +321 -321
|
@@ -1,342 +1,465 @@
|
|
|
1
|
-
import { ref, type Ref } from 'vue'
|
|
2
|
-
import { normalizeError } from './utils/errorHandler'
|
|
3
|
-
|
|
4
|
-
export interface UseMasonryPaginationOptions {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
emits: {
|
|
22
|
-
(event: '
|
|
23
|
-
(event: 'retry:
|
|
24
|
-
(event: 'retry:
|
|
25
|
-
(event: '
|
|
26
|
-
(event: 'backfill:
|
|
27
|
-
(event: 'backfill:
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
} finally {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
342
|
-
|
|
1
|
+
import { ref, type Ref } from 'vue'
|
|
2
|
+
import { normalizeError } from './utils/errorHandler'
|
|
3
|
+
|
|
4
|
+
export interface UseMasonryPaginationOptions {
|
|
5
|
+
getPage: (page: any, context?: any) => Promise<{ items: any[]; nextPage: any }>
|
|
6
|
+
context?: Ref<any>
|
|
7
|
+
masonry: Ref<any[]>
|
|
8
|
+
isLoading: Ref<boolean>
|
|
9
|
+
hasReachedEnd: Ref<boolean>
|
|
10
|
+
loadError: Ref<Error | null>
|
|
11
|
+
currentPage: Ref<any>
|
|
12
|
+
paginationHistory: Ref<any[]>
|
|
13
|
+
refreshLayout: (items: any[]) => void
|
|
14
|
+
retryMaxAttempts: number
|
|
15
|
+
retryInitialDelayMs: number
|
|
16
|
+
retryBackoffStepMs: number
|
|
17
|
+
mode: string
|
|
18
|
+
backfillDelayMs: number
|
|
19
|
+
backfillMaxCalls: number
|
|
20
|
+
pageSize: number
|
|
21
|
+
emits: {
|
|
22
|
+
(event: 'loading:start'): void
|
|
23
|
+
(event: 'retry:start', payload: { attempt: number; max: number; totalMs: number }): void
|
|
24
|
+
(event: 'retry:tick', payload: { attempt: number; remainingMs: number; totalMs: number }): void
|
|
25
|
+
(event: 'retry:stop', payload: { attempt: number; success: boolean }): void
|
|
26
|
+
(event: 'backfill:start', payload: { target: number; fetched: number; calls: number }): void
|
|
27
|
+
(event: 'backfill:tick', payload: { fetched: number; target: number; calls: number; remainingMs: number; totalMs: number }): void
|
|
28
|
+
(event: 'backfill:stop', payload: { fetched: number; calls: number; cancelled?: boolean }): void
|
|
29
|
+
(event: 'loading:stop', payload: { fetched: number }): void
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useMasonryPagination(options: UseMasonryPaginationOptions) {
|
|
34
|
+
const {
|
|
35
|
+
getPage,
|
|
36
|
+
context,
|
|
37
|
+
masonry,
|
|
38
|
+
isLoading,
|
|
39
|
+
hasReachedEnd,
|
|
40
|
+
loadError,
|
|
41
|
+
currentPage,
|
|
42
|
+
paginationHistory,
|
|
43
|
+
refreshLayout,
|
|
44
|
+
retryMaxAttempts,
|
|
45
|
+
retryInitialDelayMs,
|
|
46
|
+
retryBackoffStepMs,
|
|
47
|
+
mode,
|
|
48
|
+
backfillDelayMs,
|
|
49
|
+
backfillMaxCalls,
|
|
50
|
+
pageSize,
|
|
51
|
+
emits
|
|
52
|
+
} = options
|
|
53
|
+
|
|
54
|
+
const cancelRequested = ref(false)
|
|
55
|
+
let backfillActive = false
|
|
56
|
+
|
|
57
|
+
// Helper function to count items for a specific page
|
|
58
|
+
function countItemsForPage(page: any): number {
|
|
59
|
+
return masonry.value.filter((item: any) => item.page === page).length
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Helper function to check if an item already exists in masonry
|
|
63
|
+
function itemExists(item: any, itemsArray?: any[]): boolean {
|
|
64
|
+
if (!item || item.id == null || item.page == null) return false
|
|
65
|
+
const itemsToCheck = itemsArray || masonry.value
|
|
66
|
+
return itemsToCheck.some((existing: any) => {
|
|
67
|
+
return existing && existing.id === item.id && existing.page === item.page
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Helper function to get only new items from a response
|
|
72
|
+
function getNewItems(responseItems: any[]): any[] {
|
|
73
|
+
if (!responseItems || responseItems.length === 0) return []
|
|
74
|
+
// Create a snapshot of current masonry items to avoid reactivity issues
|
|
75
|
+
const currentItems = [...masonry.value]
|
|
76
|
+
return responseItems.filter((item: any) => {
|
|
77
|
+
if (!item || item.id == null || item.page == null) return false
|
|
78
|
+
// Check if item exists by comparing id and page
|
|
79
|
+
const exists = currentItems.some((existing: any) => {
|
|
80
|
+
return existing && existing.id === item.id && existing.page === item.page
|
|
81
|
+
})
|
|
82
|
+
return !exists
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
|
|
87
|
+
return new Promise<void>((resolve) => {
|
|
88
|
+
const total = Math.max(0, totalMs | 0)
|
|
89
|
+
const start = Date.now()
|
|
90
|
+
onTick(total, total)
|
|
91
|
+
const id = setInterval(() => {
|
|
92
|
+
// Check for cancellation
|
|
93
|
+
if (cancelRequested.value) {
|
|
94
|
+
clearInterval(id)
|
|
95
|
+
resolve()
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
const elapsed = Date.now() - start
|
|
99
|
+
const remaining = Math.max(0, total - elapsed)
|
|
100
|
+
onTick(remaining, total)
|
|
101
|
+
if (remaining <= 0) {
|
|
102
|
+
clearInterval(id)
|
|
103
|
+
resolve()
|
|
104
|
+
}
|
|
105
|
+
}, 100)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
|
|
110
|
+
let attempt = 0
|
|
111
|
+
const max = retryMaxAttempts
|
|
112
|
+
let delay = retryInitialDelayMs
|
|
113
|
+
// eslint-disable-next-line no-constant-condition
|
|
114
|
+
while (true) {
|
|
115
|
+
try {
|
|
116
|
+
const res = await fn()
|
|
117
|
+
if (attempt > 0) {
|
|
118
|
+
emits('retry:stop', { attempt, success: true })
|
|
119
|
+
}
|
|
120
|
+
return res
|
|
121
|
+
} catch (err) {
|
|
122
|
+
attempt++
|
|
123
|
+
if (attempt > max) {
|
|
124
|
+
emits('retry:stop', { attempt: attempt - 1, success: false })
|
|
125
|
+
throw err
|
|
126
|
+
}
|
|
127
|
+
emits('retry:start', { attempt, max, totalMs: delay })
|
|
128
|
+
await waitWithProgress(delay, (remaining, total) => {
|
|
129
|
+
emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
|
|
130
|
+
})
|
|
131
|
+
delay += retryBackoffStepMs
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function getContent(page: number) {
|
|
137
|
+
try {
|
|
138
|
+
const response = await fetchWithRetry(() => getPage(page, context?.value))
|
|
139
|
+
refreshLayout([...masonry.value, ...response.items])
|
|
140
|
+
return response
|
|
141
|
+
} catch (error) {
|
|
142
|
+
// Error is handled by callers (loadPage, loadNext, etc.) which set loadError
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function maybeBackfillToTarget(baselineCount: number, force = false) {
|
|
148
|
+
if (!force && mode !== 'backfill') return
|
|
149
|
+
if (backfillActive) return
|
|
150
|
+
if (cancelRequested.value) return
|
|
151
|
+
// Don't backfill if we've reached the end
|
|
152
|
+
if (hasReachedEnd.value) return
|
|
153
|
+
|
|
154
|
+
const targetCount = (baselineCount || 0) + (pageSize || 0)
|
|
155
|
+
if (!pageSize || pageSize <= 0) return
|
|
156
|
+
|
|
157
|
+
const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
|
|
158
|
+
if (lastNext == null) {
|
|
159
|
+
hasReachedEnd.value = true
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (masonry.value.length >= targetCount) return
|
|
164
|
+
|
|
165
|
+
backfillActive = true
|
|
166
|
+
// Set loading to true at the start of backfill and keep it true throughout
|
|
167
|
+
if (!isLoading.value) {
|
|
168
|
+
isLoading.value = true
|
|
169
|
+
emits('loading:start')
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
let calls = 0
|
|
173
|
+
emits('backfill:start', { target: targetCount, fetched: masonry.value.length, calls })
|
|
174
|
+
|
|
175
|
+
while (
|
|
176
|
+
masonry.value.length < targetCount &&
|
|
177
|
+
calls < backfillMaxCalls &&
|
|
178
|
+
paginationHistory.value[paginationHistory.value.length - 1] != null &&
|
|
179
|
+
!cancelRequested.value &&
|
|
180
|
+
!hasReachedEnd.value &&
|
|
181
|
+
backfillActive
|
|
182
|
+
) {
|
|
183
|
+
await waitWithProgress(backfillDelayMs, (remaining, total) => {
|
|
184
|
+
emits('backfill:tick', {
|
|
185
|
+
fetched: masonry.value.length,
|
|
186
|
+
target: targetCount,
|
|
187
|
+
calls,
|
|
188
|
+
remainingMs: remaining,
|
|
189
|
+
totalMs: total
|
|
190
|
+
})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
if (cancelRequested.value || !backfillActive) break
|
|
194
|
+
|
|
195
|
+
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
196
|
+
if (currentPage == null) {
|
|
197
|
+
hasReachedEnd.value = true
|
|
198
|
+
break
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
// Don't toggle isLoading here - keep it true throughout backfill
|
|
202
|
+
// Check cancellation before starting getContent to avoid unnecessary requests
|
|
203
|
+
if (cancelRequested.value || !backfillActive) break
|
|
204
|
+
const response = await getContent(currentPage)
|
|
205
|
+
if (cancelRequested.value || !backfillActive) break
|
|
206
|
+
// Clear error on successful load
|
|
207
|
+
loadError.value = null
|
|
208
|
+
paginationHistory.value.push(response.nextPage)
|
|
209
|
+
// Update hasReachedEnd if nextPage is null
|
|
210
|
+
if (response.nextPage == null) {
|
|
211
|
+
hasReachedEnd.value = true
|
|
212
|
+
}
|
|
213
|
+
} catch (error) {
|
|
214
|
+
// Set load error but don't break the backfill loop unless cancelled
|
|
215
|
+
if (cancelRequested.value || !backfillActive) break
|
|
216
|
+
loadError.value = normalizeError(error)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
calls++
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
emits('backfill:stop', { fetched: masonry.value.length, calls })
|
|
223
|
+
} finally {
|
|
224
|
+
backfillActive = false
|
|
225
|
+
// Only set loading to false when backfill completes or is cancelled
|
|
226
|
+
isLoading.value = false
|
|
227
|
+
emits('loading:stop', { fetched: masonry.value.length })
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function loadPage(page: number) {
|
|
232
|
+
if (isLoading.value) return
|
|
233
|
+
// Starting a new load should clear any previous cancel request
|
|
234
|
+
cancelRequested.value = false
|
|
235
|
+
if (!isLoading.value) {
|
|
236
|
+
isLoading.value = true
|
|
237
|
+
emits('loading:start')
|
|
238
|
+
}
|
|
239
|
+
// Reset hasReachedEnd and loadError when loading a new page
|
|
240
|
+
hasReachedEnd.value = false
|
|
241
|
+
loadError.value = null
|
|
242
|
+
try {
|
|
243
|
+
const baseline = masonry.value.length
|
|
244
|
+
if (cancelRequested.value) return
|
|
245
|
+
const response = await getContent(page)
|
|
246
|
+
if (cancelRequested.value) return
|
|
247
|
+
// Clear error on successful load
|
|
248
|
+
loadError.value = null
|
|
249
|
+
currentPage.value = page // Track the current page
|
|
250
|
+
paginationHistory.value.push(response.nextPage)
|
|
251
|
+
// Update hasReachedEnd if nextPage is null
|
|
252
|
+
if (response.nextPage == null) {
|
|
253
|
+
hasReachedEnd.value = true
|
|
254
|
+
}
|
|
255
|
+
await maybeBackfillToTarget(baseline)
|
|
256
|
+
return response
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// Set load error - error is handled and exposed to UI via loadError
|
|
259
|
+
loadError.value = normalizeError(error)
|
|
260
|
+
throw error
|
|
261
|
+
} finally {
|
|
262
|
+
isLoading.value = false
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function loadNext() {
|
|
267
|
+
if (isLoading.value) return
|
|
268
|
+
// Don't load if we've already reached the end
|
|
269
|
+
if (hasReachedEnd.value) return
|
|
270
|
+
// Starting a new load should clear any previous cancel request
|
|
271
|
+
cancelRequested.value = false
|
|
272
|
+
if (!isLoading.value) {
|
|
273
|
+
isLoading.value = true
|
|
274
|
+
emits('loading:start')
|
|
275
|
+
}
|
|
276
|
+
// Clear error when attempting to load
|
|
277
|
+
loadError.value = null
|
|
278
|
+
try {
|
|
279
|
+
const baseline = masonry.value.length
|
|
280
|
+
if (cancelRequested.value) return
|
|
281
|
+
|
|
282
|
+
// Refresh mode: check if current page needs refreshing before loading next
|
|
283
|
+
if (mode === 'refresh' && currentPage.value != null) {
|
|
284
|
+
const currentPageItemCount = countItemsForPage(currentPage.value)
|
|
285
|
+
|
|
286
|
+
// If current page has fewer items than pageSize, refresh it first
|
|
287
|
+
if (currentPageItemCount < pageSize) {
|
|
288
|
+
const response = await fetchWithRetry(() => getPage(currentPage.value, context?.value))
|
|
289
|
+
if (cancelRequested.value) return
|
|
290
|
+
|
|
291
|
+
// Get only new items that don't already exist
|
|
292
|
+
// We need to check against the current masonry state at this moment
|
|
293
|
+
const currentMasonrySnapshot = [...masonry.value]
|
|
294
|
+
const newItems = response.items.filter((item: any) => {
|
|
295
|
+
if (!item || item.id == null || item.page == null) return false
|
|
296
|
+
return !currentMasonrySnapshot.some((existing: any) => {
|
|
297
|
+
return existing && existing.id === item.id && existing.page === item.page
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// Append only new items to masonry (same pattern as getContent)
|
|
302
|
+
if (newItems.length > 0) {
|
|
303
|
+
const updatedItems = [...masonry.value, ...newItems]
|
|
304
|
+
refreshLayout(updatedItems)
|
|
305
|
+
// Wait a tick for masonry to update
|
|
306
|
+
await new Promise(resolve => setTimeout(resolve, 0))
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Clear error on successful load
|
|
310
|
+
loadError.value = null
|
|
311
|
+
|
|
312
|
+
// If no new items were found, automatically proceed to next page
|
|
313
|
+
// This means the current page has no more items available
|
|
314
|
+
if (newItems.length === 0) {
|
|
315
|
+
const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
|
|
316
|
+
if (nextPageToLoad == null) {
|
|
317
|
+
hasReachedEnd.value = true
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
const nextResponse = await getContent(nextPageToLoad)
|
|
321
|
+
if (cancelRequested.value) return
|
|
322
|
+
loadError.value = null
|
|
323
|
+
currentPage.value = nextPageToLoad
|
|
324
|
+
paginationHistory.value.push(nextResponse.nextPage)
|
|
325
|
+
if (nextResponse.nextPage == null) {
|
|
326
|
+
hasReachedEnd.value = true
|
|
327
|
+
}
|
|
328
|
+
await maybeBackfillToTarget(baseline)
|
|
329
|
+
return nextResponse
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// If we now have enough items for current page, proceed to next page
|
|
333
|
+
// Re-check count after items have been added
|
|
334
|
+
const updatedCount = countItemsForPage(currentPage.value)
|
|
335
|
+
if (updatedCount >= pageSize) {
|
|
336
|
+
// Current page is now full, proceed with normal next page loading
|
|
337
|
+
const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
|
|
338
|
+
if (nextPageToLoad == null) {
|
|
339
|
+
hasReachedEnd.value = true
|
|
340
|
+
return
|
|
341
|
+
}
|
|
342
|
+
const nextResponse = await getContent(nextPageToLoad)
|
|
343
|
+
if (cancelRequested.value) return
|
|
344
|
+
loadError.value = null
|
|
345
|
+
currentPage.value = nextPageToLoad
|
|
346
|
+
paginationHistory.value.push(nextResponse.nextPage)
|
|
347
|
+
if (nextResponse.nextPage == null) {
|
|
348
|
+
hasReachedEnd.value = true
|
|
349
|
+
}
|
|
350
|
+
await maybeBackfillToTarget(baseline)
|
|
351
|
+
return nextResponse
|
|
352
|
+
} else {
|
|
353
|
+
// Still not enough items, but we refreshed - return the refresh response
|
|
354
|
+
return response
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Normal flow: load next page
|
|
360
|
+
const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
|
|
361
|
+
// Don't load if nextPageToLoad is null
|
|
362
|
+
if (nextPageToLoad == null) {
|
|
363
|
+
hasReachedEnd.value = true
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
const response = await getContent(nextPageToLoad)
|
|
367
|
+
if (cancelRequested.value) return
|
|
368
|
+
// Clear error on successful load
|
|
369
|
+
loadError.value = null
|
|
370
|
+
currentPage.value = nextPageToLoad // Track the current page
|
|
371
|
+
paginationHistory.value.push(response.nextPage)
|
|
372
|
+
// Update hasReachedEnd if nextPage is null
|
|
373
|
+
if (response.nextPage == null) {
|
|
374
|
+
hasReachedEnd.value = true
|
|
375
|
+
}
|
|
376
|
+
await maybeBackfillToTarget(baseline)
|
|
377
|
+
return response
|
|
378
|
+
} catch (error) {
|
|
379
|
+
// Set load error - error is handled and exposed to UI via loadError
|
|
380
|
+
loadError.value = normalizeError(error)
|
|
381
|
+
throw error
|
|
382
|
+
} finally {
|
|
383
|
+
isLoading.value = false
|
|
384
|
+
emits('loading:stop', { fetched: masonry.value.length })
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function refreshCurrentPage() {
|
|
389
|
+
if (isLoading.value) return
|
|
390
|
+
cancelRequested.value = false
|
|
391
|
+
isLoading.value = true
|
|
392
|
+
emits('loading:start')
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
// Use the tracked current page
|
|
396
|
+
const pageToRefresh = currentPage.value
|
|
397
|
+
|
|
398
|
+
if (pageToRefresh == null) {
|
|
399
|
+
console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Clear existing items
|
|
404
|
+
masonry.value = []
|
|
405
|
+
// Reset end flag when refreshing
|
|
406
|
+
hasReachedEnd.value = false
|
|
407
|
+
// Reset error flag when refreshing
|
|
408
|
+
loadError.value = null
|
|
409
|
+
|
|
410
|
+
// Reset pagination history to just the current page
|
|
411
|
+
paginationHistory.value = [pageToRefresh]
|
|
412
|
+
|
|
413
|
+
// Reload the current page
|
|
414
|
+
const response = await getContent(pageToRefresh)
|
|
415
|
+
if (cancelRequested.value) return
|
|
416
|
+
|
|
417
|
+
// Clear error on successful load
|
|
418
|
+
loadError.value = null
|
|
419
|
+
// Update pagination state
|
|
420
|
+
currentPage.value = pageToRefresh
|
|
421
|
+
paginationHistory.value.push(response.nextPage)
|
|
422
|
+
// Update hasReachedEnd if nextPage is null
|
|
423
|
+
if (response.nextPage == null) {
|
|
424
|
+
hasReachedEnd.value = true
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Optionally backfill if needed
|
|
428
|
+
const baseline = masonry.value.length
|
|
429
|
+
await maybeBackfillToTarget(baseline)
|
|
430
|
+
|
|
431
|
+
return response
|
|
432
|
+
} catch (error) {
|
|
433
|
+
// Set load error - error is handled and exposed to UI via loadError
|
|
434
|
+
loadError.value = normalizeError(error)
|
|
435
|
+
throw error
|
|
436
|
+
} finally {
|
|
437
|
+
isLoading.value = false
|
|
438
|
+
emits('loading:stop', { fetched: masonry.value.length })
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function cancelLoad() {
|
|
443
|
+
const wasBackfilling = backfillActive
|
|
444
|
+
cancelRequested.value = true
|
|
445
|
+
isLoading.value = false
|
|
446
|
+
// Set backfillActive to false to immediately stop backfilling
|
|
447
|
+
// The backfill loop checks this flag and will exit on the next iteration
|
|
448
|
+
backfillActive = false
|
|
449
|
+
// If backfill was active, emit stop event immediately
|
|
450
|
+
if (wasBackfilling) {
|
|
451
|
+
emits('backfill:stop', { fetched: masonry.value.length, calls: 0, cancelled: true })
|
|
452
|
+
}
|
|
453
|
+
emits('loading:stop', { fetched: masonry.value.length })
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
loadPage,
|
|
458
|
+
loadNext,
|
|
459
|
+
refreshCurrentPage,
|
|
460
|
+
cancelLoad,
|
|
461
|
+
maybeBackfillToTarget,
|
|
462
|
+
getContent
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|