|
21 | 21 | let groupName = $state(''); |
22 | 22 | let debounceTimer: NodeJS.Timeout; |
23 | 23 |
|
24 | | - async function loadMessages() { |
| 24 | + // Pagination and loading state |
| 25 | + let isLoading = $state(true); |
| 26 | + let currentPage = $state(1); |
| 27 | + let totalPages = $state(1); |
| 28 | + let totalChats = $state(0); |
| 29 | + let hasMorePages = $state(false); |
| 30 | +
|
| 31 | + async function loadMessages(page = 1, append = false) { |
25 | 32 | try { |
26 | | - const { data } = await apiClient.get<{ chats: Chat[] }>('/api/chats'); |
| 33 | + isLoading = true; |
| 34 | + const { data } = await apiClient.get<{ |
| 35 | + chats: Chat[]; |
| 36 | + total: number; |
| 37 | + page: number; |
| 38 | + totalPages: number; |
| 39 | + }>(`/api/chats?page=${page}&limit=10`); |
| 40 | +
|
27 | 41 | const { data: userData } = await apiClient.get('/api/users'); |
28 | 42 | currentUserId = userData.id; |
29 | 43 |
|
30 | 44 | console.log('Raw chat data from API:', data.chats); |
31 | 45 |
|
32 | | - // Show all chats (direct messages and groups) in one unified list |
33 | | - messages = data.chats.map((c) => { |
| 46 | + // Update pagination info |
| 47 | + currentPage = data.page; |
| 48 | + totalPages = data.totalPages; |
| 49 | + totalChats = data.total; |
| 50 | + hasMorePages = data.page < data.totalPages; |
| 51 | +
|
| 52 | + // Transform chats to messages |
| 53 | + const newMessages = data.chats.map((c) => { |
34 | 54 | const members = c.participants.filter((u) => u.id !== userData.id); |
35 | 55 | const memberNames = members.map((m) => m.name ?? m.handle ?? m.ename); |
36 | 56 | const isGroup = members.length > 1; |
|
57 | 77 | name: displayName |
58 | 78 | }; |
59 | 79 | }); |
| 80 | +
|
| 81 | + // Append or replace messages based on pagination |
| 82 | + if (append) { |
| 83 | + messages = [...messages, ...newMessages]; |
| 84 | + } else { |
| 85 | + messages = newMessages; |
| 86 | + } |
60 | 87 | } catch (error) { |
61 | 88 | console.error('Failed to load messages:', error); |
| 89 | + } finally { |
| 90 | + isLoading = false; |
| 91 | + } |
| 92 | + } |
| 93 | +
|
| 94 | + async function loadNextPage() { |
| 95 | + if (hasMorePages && !isLoading) { |
| 96 | + await loadMessages(currentPage + 1, true); |
| 97 | + } |
| 98 | + } |
| 99 | +
|
| 100 | + async function loadPreviousPage() { |
| 101 | + if (currentPage > 1 && !isLoading) { |
| 102 | + await loadMessages(currentPage - 1, false); |
| 103 | + } |
| 104 | + } |
| 105 | +
|
| 106 | + async function goToPage(page: number) { |
| 107 | + if (page >= 1 && page <= totalPages && !isLoading) { |
| 108 | + await loadMessages(page, false); |
62 | 109 | } |
63 | 110 | } |
64 | 111 |
|
|
197 | 244 | // Navigate to messages and refresh the feed |
198 | 245 | goto('/messages'); |
199 | 246 | // Refresh the messages to show the newly created group |
200 | | - await loadMessages(); |
| 247 | + await loadMessages(1, false); |
201 | 248 | } catch (err) { |
202 | 249 | console.error('Failed to create group:', err); |
203 | 250 | alert('Failed to create group. Please try again.'); |
|
218 | 265 | </Button> |
219 | 266 | </div> |
220 | 267 |
|
221 | | - {#if messages.length > 0} |
| 268 | + {#if isLoading && messages.length === 0} |
| 269 | + <div class="flex items-center justify-center py-8"> |
| 270 | + <div |
| 271 | + class="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" |
| 272 | + ></div> |
| 273 | + <span class="ml-3 text-gray-500">Loading chats...</span> |
| 274 | + </div> |
| 275 | + {:else if messages.length > 0} |
222 | 276 | {#each messages as message} |
223 | 277 | <Message |
224 | 278 | class="mb-2" |
|
232 | 286 | }} |
233 | 287 | /> |
234 | 288 | {/each} |
235 | | - {/if} |
236 | 289 |
|
237 | | - {#if messages.length === 0} |
| 290 | + <!-- Pagination Controls --> |
| 291 | + {#if totalPages > 1} |
| 292 | + <div class="mt-6 flex items-center justify-between"> |
| 293 | + <Button |
| 294 | + variant="secondary" |
| 295 | + size="sm" |
| 296 | + callback={loadPreviousPage} |
| 297 | + disabled={currentPage <= 1 || isLoading} |
| 298 | + > |
| 299 | + Previous |
| 300 | + </Button> |
| 301 | + |
| 302 | + <div class="flex items-center space-x-2"> |
| 303 | + {#if totalPages <= 7} |
| 304 | + {#each Array.from({ length: totalPages }, (_, i) => i + 1) as page} |
| 305 | + <button |
| 306 | + class="rounded px-3 py-1 text-sm {page === currentPage |
| 307 | + ? 'bg-blue-600 text-white' |
| 308 | + : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}" |
| 309 | + onclick={() => goToPage(page)} |
| 310 | + disabled={isLoading} |
| 311 | + > |
| 312 | + {page} |
| 313 | + </button> |
| 314 | + {/each} |
| 315 | + {:else} |
| 316 | + <!-- Show first page --> |
| 317 | + <button |
| 318 | + class="rounded px-3 py-1 text-sm {1 === currentPage |
| 319 | + ? 'bg-blue-600 text-white' |
| 320 | + : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}" |
| 321 | + onclick={() => goToPage(1)} |
| 322 | + disabled={isLoading} |
| 323 | + > |
| 324 | + 1 |
| 325 | + </button> |
| 326 | + |
| 327 | + {#if currentPage > 3} |
| 328 | + <span class="text-gray-500">...</span> |
| 329 | + {/if} |
| 330 | + |
| 331 | + <!-- Show pages around current page --> |
| 332 | + {#each Array.from({ length: Math.min(3, totalPages - 2) }, (_, i) => { |
| 333 | + const start = Math.max(2, currentPage - 1); |
| 334 | + return Math.min(start + i, totalPages - 1); |
| 335 | + }).filter((page, index, arr) => arr.indexOf(page) === index) as page} |
| 336 | + <button |
| 337 | + class="rounded px-3 py-1 text-sm {page === currentPage |
| 338 | + ? 'bg-blue-600 text-white' |
| 339 | + : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}" |
| 340 | + onclick={() => goToPage(page)} |
| 341 | + disabled={isLoading} |
| 342 | + > |
| 343 | + {page} |
| 344 | + </button> |
| 345 | + {/each} |
| 346 | + |
| 347 | + {#if currentPage < totalPages - 2} |
| 348 | + <span class="text-gray-500">...</span> |
| 349 | + {/if} |
| 350 | + |
| 351 | + <!-- Show last page --> |
| 352 | + {#if totalPages > 1} |
| 353 | + <button |
| 354 | + class="rounded px-3 py-1 text-sm {totalPages === currentPage |
| 355 | + ? 'bg-blue-600 text-white' |
| 356 | + : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}" |
| 357 | + onclick={() => goToPage(totalPages)} |
| 358 | + disabled={isLoading} |
| 359 | + > |
| 360 | + {totalPages} |
| 361 | + </button> |
| 362 | + {/if} |
| 363 | + {/if} |
| 364 | + </div> |
| 365 | + |
| 366 | + <Button |
| 367 | + variant="secondary" |
| 368 | + size="sm" |
| 369 | + callback={loadNextPage} |
| 370 | + disabled={!hasMorePages || isLoading} |
| 371 | + > |
| 372 | + Next |
| 373 | + </Button> |
| 374 | + </div> |
| 375 | + |
| 376 | + <div class="mt-2 text-center text-sm text-gray-500"> |
| 377 | + Page {currentPage} of {totalPages} • {totalChats} total chats |
| 378 | + </div> |
| 379 | + {/if} |
| 380 | + {:else if !isLoading} |
238 | 381 | <div class="w-full px-5 py-5 text-center text-sm text-gray-500"> |
239 | 382 | You don't have any messages yet. Start a Direct Message by searching a name. |
240 | 383 | </div> |
241 | 384 | {/if} |
242 | 385 |
|
243 | 386 | {#if openNewChatModal} |
244 | | - <div class="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black"> |
| 387 | + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> |
245 | 388 | <div |
246 | 389 | class="w-[90vw] max-w-md rounded-3xl border border-gray-200 bg-white p-6 shadow-xl" |
247 | 390 | > |
|
250 | 393 | <button |
251 | 394 | onclick={() => (openNewChatModal = false)} |
252 | 395 | class="rounded-full p-2 hover:bg-gray-100" |
| 396 | + aria-label="Close modal" |
253 | 397 | > |
254 | 398 | <svg |
255 | 399 | class="h-5 w-5 text-gray-500" |
|
328 | 472 | <Button |
329 | 473 | size="sm" |
330 | 474 | variant="secondary" |
331 | | - callback={() => (openNewChatModal = false)} |
| 475 | + callback={() => { |
| 476 | + openNewChatModal = false; |
| 477 | + }} |
332 | 478 | > |
333 | 479 | Cancel |
334 | 480 | </Button> |
|
348 | 494 |
|
349 | 495 | <!-- New Group Modal --> |
350 | 496 | {#if openNewGroupModal} |
351 | | - <div class="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black"> |
| 497 | + <div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"> |
352 | 498 | <div |
353 | 499 | class="w-[90vw] max-w-md rounded-3xl border border-gray-200 bg-white p-6 shadow-xl" |
354 | 500 | > |
|
357 | 503 | <button |
358 | 504 | onclick={() => (openNewGroupModal = false)} |
359 | 505 | class="rounded-full p-2 hover:bg-gray-100" |
| 506 | + aria-label="Close modal" |
360 | 507 | > |
361 | 508 | <svg |
362 | 509 | class="h-5 w-5 text-gray-500" |
|
391 | 538 |
|
392 | 539 | <!-- Member Search --> |
393 | 540 | <div> |
394 | | - <label class="mb-2 block text-sm font-medium text-gray-700"> |
| 541 | + <label |
| 542 | + for="memberSearch" |
| 543 | + class="mb-2 block text-sm font-medium text-gray-700" |
| 544 | + > |
395 | 545 | Search Users by Name |
396 | 546 | </label> |
397 | 547 | <Input |
| 548 | + id="memberSearch" |
398 | 549 | type="text" |
399 | 550 | bind:value={searchValue} |
400 | 551 | placeholder="Type a name to search..." |
|
466 | 617 | <button |
467 | 618 | onclick={() => toggleMemberSelection(member.id)} |
468 | 619 | class="text-blue-600 hover:text-blue-800" |
| 620 | + aria-label="Remove member" |
469 | 621 | > |
470 | 622 | <svg |
471 | 623 | class="h-4 w-4" |
|
492 | 644 | <Button |
493 | 645 | size="sm" |
494 | 646 | variant="secondary" |
495 | | - callback={() => (openNewGroupModal = false)} |
| 647 | + callback={() => { |
| 648 | + openNewGroupModal = false; |
| 649 | + }} |
496 | 650 | > |
497 | 651 | Cancel |
498 | 652 | </Button> |
|
0 commit comments