vmi-virtual-memorial/vmi-wd-frontend / cc2f7b1

Browse files

add pagination for pages that exceed limits

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cc2f7b128fb053cc9f8182d0e853eac1cc1d9865
Parents
67edab5
Tree
438cac0

3 changed files

StatusFile+-
M app/memorial/conflict/[id]/page.tsx 76 33
A components/Pagination.tsx 157 0
M lib/api.ts 1 1
app/memorial/conflict/[id]/page.tsxmodified
@@ -6,26 +6,29 @@ import { useParams } from 'next/navigation';
66
 import { getConflicts, getPeopleByConflict, Conflict, PersonDetail } from '@/lib/api';
77
 import Header from '@/components/Header';
88
 import DocumentIcon from '@/components/DocumentIcon';
9
+import Pagination from '@/components/Pagination';
910
 
1011
 export default function ConflictPage() {
1112
   const params = useParams();
1213
   const conflictId = parseInt(params.id as string);
13
-  
14
+
1415
   const [conflict, setConflict] = useState<Conflict | null>(null);
1516
   const [people, setPeople] = useState<PersonDetail[]>([]);
1617
   const [loading, setLoading] = useState(true);
1718
   const [error, setError] = useState<string | null>(null);
19
+  const [currentPage, setCurrentPage] = useState(1);
20
+  const [itemsPerPage, setItemsPerPage] = useState(30);
1821
 
19
-  useEffect(() => {
22
+useEffect(() => {
2023
     async function fetchData() {
2124
       try {
2225
         const conflicts = await getConflicts();
2326
         const currentConflict = conflicts.find(c => c.id === conflictId);
24
-        
27
+
2528
         if (!currentConflict) {
2629
           throw new Error('Conflict not found');
2730
         }
28
-        
31
+
2932
         setConflict(currentConflict);
3033
         const peopleData = await getPeopleByConflict(conflictId);
3134
         setPeople(peopleData);
@@ -36,10 +39,29 @@ export default function ConflictPage() {
3639
         setLoading(false);
3740
       }
3841
     }
39
-    
42
+
4043
     fetchData();
4144
   }, [conflictId]);
4245
 
46
+  // Calculate paginated data
47
+  const totalItems = people.length;
48
+  const totalPages = Math.ceil(totalItems / itemsPerPage);
49
+  const startIndex = (currentPage - 1) * itemsPerPage;
50
+  const endIndex = startIndex + itemsPerPage;
51
+  const paginatedPeople = people.slice(startIndex, endIndex);
52
+
53
+  // Handlers for pagination
54
+  const handlePageChange = (page: number) => {
55
+    setCurrentPage(page);
56
+    // Scroll to top of results
57
+    window.scrollTo({ top: 0, behavior: 'smooth' });
58
+  };
59
+
60
+  const handleItemsPerPageChange = (newItemsPerPage: number) => {
61
+    setItemsPerPage(newItemsPerPage);
62
+    setCurrentPage(1); // Reset to first page when changing items per page
63
+  };
64
+
4365
   if (loading) {
4466
     return (
4567
       <div className="min-h-screen bg-vmi-cream flex items-center justify-center">
@@ -97,37 +119,58 @@ export default function ConflictPage() {
97119
           <h2 className="text-3xl font-bold mb-8 text-center text-vmi-red">
98120
             Honor Roll
99121
           </h2>
100
-          
122
+
101123
           {people.length === 0 ? (
102124
             <p className="text-center text-gray-600 text-lg">No casualties recorded yet.</p>
103125
           ) : (
104
-            <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
105
-              {people.map((person) => (
106
-                <Link
107
-                  key={person.id}
108
-                  href={`/memorial/person/${person.id}`}
109
-                  className="block p-6 border-2 border-gray-200 rounded-lg hover:border-vmi-gold hover:bg-vmi-light-gold transition-all duration-200 group"
110
-                >
111
-                  <h3 className="text-xl font-bold text-gray-800 group-hover:text-vmi-red transition-colors mb-2 flex items-center gap-2">
112
-                    {person.full_display_name ? 
113
-                      person.full_display_name.replace(person.rank + ' ', '').replace(person.rank + ', ', '') 
114
-                      : person.display_name.replace(person.rank + ' ', '').replace(person.rank + ', ', '')}
115
-                    {person.pdf_key && <DocumentIcon className="flex-shrink-0" />}
116
-                  </h3>
117
-                  {person.rank && (
118
-                    <p className="text-gray-700 font-semibold">{person.rank}</p>
119
-                  )}
120
-                  {person.unit && (
121
-                    <p className="text-gray-600 text-sm italic">{person.unit}</p>
122
-                  )}
123
-                  {person.death_description && (
124
-                    <p className="text-gray-600 text-sm italic mt-3 line-clamp-3">
125
-                      {person.death_description}
126
-                    </p>
127
-                  )}
128
-                </Link>
129
-              ))}
130
-            </div>
126
+            <>
127
+              {/* Pagination Controls - Top */}
128
+              <Pagination
129
+                currentPage={currentPage}
130
+                totalItems={totalItems}
131
+                itemsPerPage={itemsPerPage}
132
+                onPageChange={handlePageChange}
133
+                onItemsPerPageChange={handleItemsPerPageChange}
134
+              />
135
+
136
+              {/* People Grid */}
137
+              <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
138
+                {paginatedPeople.map((person) => (
139
+                  <Link
140
+                    key={person.id}
141
+                    href={`/memorial/person/${person.id}`}
142
+                    className="block p-6 border-2 border-gray-200 rounded-lg hover:border-vmi-gold hover:bg-vmi-light-gold transition-all duration-200 group"
143
+                  >
144
+                    <h3 className="text-xl font-bold text-gray-800 group-hover:text-vmi-red transition-colors mb-2 flex items-center gap-2">
145
+                      {person.full_display_name ?
146
+                        person.full_display_name.replace(person.rank + ' ', '').replace(person.rank + ', ', '')
147
+                        : person.display_name.replace(person.rank + ' ', '').replace(person.rank + ', ', '')}
148
+                      {person.pdf_key && <DocumentIcon className="flex-shrink-0" />}
149
+                    </h3>
150
+                    {person.rank && (
151
+                      <p className="text-gray-700 font-semibold">{person.rank}</p>
152
+                    )}
153
+                    {person.unit && (
154
+                      <p className="text-gray-600 text-sm italic">{person.unit}</p>
155
+                    )}
156
+                    {person.death_description && (
157
+                      <p className="text-gray-600 text-sm italic mt-3 line-clamp-3">
158
+                        {person.death_description}
159
+                      </p>
160
+                    )}
161
+                  </Link>
162
+                ))}
163
+              </div>
164
+
165
+              {/* Pagination Controls - Bottom */}
166
+              <Pagination
167
+                currentPage={currentPage}
168
+                totalItems={totalItems}
169
+                itemsPerPage={itemsPerPage}
170
+                onPageChange={handlePageChange}
171
+                onItemsPerPageChange={handleItemsPerPageChange}
172
+              />
173
+            </>
131174
           )}
132175
         </div>
133176
       </main>
components/Pagination.tsxadded
@@ -0,0 +1,157 @@
1
+import React from 'react';
2
+
3
+interface PaginationProps {
4
+  currentPage: number;
5
+  totalItems: number;
6
+  itemsPerPage: number;
7
+  onPageChange: (page: number) => void;
8
+  onItemsPerPageChange: (itemsPerPage: number) => void;
9
+  itemsPerPageOptions?: number[];
10
+}
11
+
12
+export default function Pagination({
13
+  currentPage,
14
+  totalItems,
15
+  itemsPerPage,
16
+  onPageChange,
17
+  onItemsPerPageChange,
18
+  itemsPerPageOptions = [30, 40, 50],
19
+}: PaginationProps) {
20
+  const totalPages = Math.ceil(totalItems / itemsPerPage);
21
+
22
+  // Don't show pagination if there's only one page or less
23
+  if (totalPages <= 1) {
24
+    return null;
25
+  }
26
+
27
+  const startItem = (currentPage - 1) * itemsPerPage + 1;
28
+  const endItem = Math.min(currentPage * itemsPerPage, totalItems);
29
+
30
+  // Generate page numbers to display
31
+  const getPageNumbers = () => {
32
+    const pages: (number | string)[] = [];
33
+    const maxVisiblePages = 7;
34
+
35
+    if (totalPages <= maxVisiblePages) {
36
+      // Show all pages if total is small
37
+      for (let i = 1; i <= totalPages; i++) {
38
+        pages.push(i);
39
+      }
40
+    } else {
41
+      // Always show first page
42
+      pages.push(1);
43
+
44
+      if (currentPage <= 4) {
45
+        // Near the beginning
46
+        for (let i = 2; i <= 5; i++) {
47
+          pages.push(i);
48
+        }
49
+        pages.push('...');
50
+        pages.push(totalPages);
51
+      } else if (currentPage >= totalPages - 3) {
52
+        // Near the end
53
+        pages.push('...');
54
+        for (let i = totalPages - 4; i <= totalPages; i++) {
55
+          pages.push(i);
56
+        }
57
+      } else {
58
+        // In the middle
59
+        pages.push('...');
60
+        for (let i = currentPage - 1; i <= currentPage + 1; i++) {
61
+          pages.push(i);
62
+        }
63
+        pages.push('...');
64
+        pages.push(totalPages);
65
+      }
66
+    }
67
+
68
+    return pages;
69
+  };
70
+
71
+  const pageNumbers = getPageNumbers();
72
+
73
+  return (
74
+    <div className="flex flex-col sm:flex-row items-center justify-between gap-4 py-6">
75
+      {/* Items per page selector */}
76
+      <div className="flex items-center gap-3">
77
+        <label htmlFor="items-per-page" className="text-gray-700 font-semibold">
78
+          Per page:
79
+        </label>
80
+        <select
81
+          id="items-per-page"
82
+          value={itemsPerPage}
83
+          onChange={(e) => onItemsPerPageChange(Number(e.target.value))}
84
+          className="px-3 py-2 border-2 border-gray-300 rounded bg-white text-gray-800 font-semibold hover:border-vmi-gold focus:border-vmi-gold focus:outline-none focus:ring-2 focus:ring-vmi-light-gold transition-colors"
85
+        >
86
+          {itemsPerPageOptions.map((option) => (
87
+            <option key={option} value={option}>
88
+              {option}
89
+            </option>
90
+          ))}
91
+          <option value={totalItems}>All ({totalItems})</option>
92
+        </select>
93
+        <span className="text-gray-600 text-sm">
94
+          Showing {startItem}-{endItem} of {totalItems}
95
+        </span>
96
+      </div>
97
+
98
+      {/* Page navigation */}
99
+      <div className="flex items-center gap-2">
100
+        {/* Previous button */}
101
+        <button
102
+          onClick={() => onPageChange(currentPage - 1)}
103
+          disabled={currentPage === 1}
104
+          className="px-4 py-2 border-2 border-gray-300 rounded font-semibold text-gray-700 hover:border-vmi-gold hover:bg-vmi-light-gold disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-all"
105
+          aria-label="Previous page"
106
+        >
107
+          ← Prev
108
+        </button>
109
+
110
+        {/* Page numbers */}
111
+        <div className="flex gap-1">
112
+          {pageNumbers.map((page, index) => {
113
+            if (page === '...') {
114
+              return (
115
+                <span
116
+                  key={`ellipsis-${index}`}
117
+                  className="px-3 py-2 text-gray-500"
118
+                >
119
+                  ...
120
+                </span>
121
+              );
122
+            }
123
+
124
+            const pageNum = page as number;
125
+            const isCurrentPage = pageNum === currentPage;
126
+
127
+            return (
128
+              <button
129
+                key={pageNum}
130
+                onClick={() => onPageChange(pageNum)}
131
+                className={`px-4 py-2 border-2 rounded font-semibold transition-all ${
132
+                  isCurrentPage
133
+                    ? 'bg-vmi-red text-white border-vmi-red'
134
+                    : 'border-gray-300 text-gray-700 hover:border-vmi-gold hover:bg-vmi-light-gold'
135
+                }`}
136
+                aria-label={`Page ${pageNum}`}
137
+                aria-current={isCurrentPage ? 'page' : undefined}
138
+              >
139
+                {pageNum}
140
+              </button>
141
+            );
142
+          })}
143
+        </div>
144
+
145
+        {/* Next button */}
146
+        <button
147
+          onClick={() => onPageChange(currentPage + 1)}
148
+          disabled={currentPage === totalPages}
149
+          className="px-4 py-2 border-2 border-gray-300 rounded font-semibold text-gray-700 hover:border-vmi-gold hover:bg-vmi-light-gold disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:border-gray-300 disabled:hover:bg-transparent transition-all"
150
+          aria-label="Next page"
151
+        >
152
+          Next →
153
+        </button>
154
+      </div>
155
+    </div>
156
+  );
157
+}
lib/api.tsmodified
@@ -140,7 +140,7 @@ export async function getConflicts(): Promise<Conflict[]> {
140140
 
141141
 // Fetch people by conflict
142142
 export async function getPeopleByConflict(conflictId: number): Promise<PersonDetail[]> {
143
-  const response = await fetch(`${API_BASE_URL}/memorial/persons/?conflict=${conflictId}`);
143
+  const response = await fetch(`${API_BASE_URL}/memorial/persons/?conflict=${conflictId}&paginate=false`);
144144
   if (!response.ok) {
145145
     throw new Error('Failed to fetch people');
146146
   }