TypeScript · 9853 bytes Raw Blame History
1 'use client';
2
3 import { useState, useEffect } from 'react';
4 import Link from 'next/link';
5 import Header from '@/components/Header';
6 import DocumentIcon from '@/components/DocumentIcon';
7 import { PersonDetail } from '@/lib/api';
8
9 interface ConflictWithCasualties {
10 id: number;
11 name: string;
12 start_year: number;
13 end_year: number | null;
14 description: string;
15 casualty_count: number;
16 casualties: PersonDetail[];
17 }
18
19 export default function MemorialIndexPage() {
20 const [conflicts, setConflicts] = useState<ConflictWithCasualties[]>([]);
21 const [loading, setLoading] = useState(true);
22 const [error, setError] = useState<string | null>(null);
23 const [expandedConflicts, setExpandedConflicts] = useState<Set<number>>(new Set());
24 const [sortBy, setSortBy] = useState<'alphabetical' | 'class_year'>('alphabetical');
25 const [isInitialLoad, setIsInitialLoad] = useState(true);
26
27 useEffect(() => {
28 async function fetchData() {
29 try {
30 const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api';
31 const response = await fetch(`${apiUrl}/memorial/index/?sort=${sortBy}`);
32
33 if (!response.ok) {
34 throw new Error('Failed to fetch memorial index');
35 }
36
37 const data = await response.json();
38 setConflicts(data);
39
40 // By default, expand conflicts with casualties (only on first load)
41 if (isInitialLoad) {
42 const defaultExpanded = new Set<number>(
43 data.filter((c: ConflictWithCasualties) => c.casualty_count > 0).map((c: ConflictWithCasualties) => c.id)
44 );
45 setExpandedConflicts(defaultExpanded);
46 setIsInitialLoad(false);
47 }
48 } catch (err) {
49 setError(err instanceof Error ? err.message : 'Failed to load data');
50 console.error(err);
51 } finally {
52 setLoading(false);
53 }
54 }
55
56 fetchData();
57 }, [sortBy, isInitialLoad]);
58
59 const toggleConflict = (conflictId: number) => {
60 const newExpanded = new Set(expandedConflicts);
61 if (newExpanded.has(conflictId)) {
62 newExpanded.delete(conflictId);
63 } else {
64 newExpanded.add(conflictId);
65 }
66 setExpandedConflicts(newExpanded);
67 };
68
69 const toggleAll = () => {
70 if (expandedConflicts.size === conflicts.length) {
71 setExpandedConflicts(new Set<number>());
72 } else {
73 setExpandedConflicts(new Set<number>(conflicts.map(c => c.id)));
74 }
75 };
76
77 if (loading) {
78 return (
79 <div className="min-h-screen bg-vmi-cream flex items-center justify-center">
80 <p className="text-gray-600 text-xl">Loading memorial index...</p>
81 </div>
82 );
83 }
84
85 if (error) {
86 return (
87 <div className="min-h-screen bg-vmi-cream flex items-center justify-center">
88 <div className="text-center">
89 <p className="text-red-600 mb-4 text-xl">{error}</p>
90 <Link href="/" className="text-vmi-red hover:text-vmi-dark-red underline font-semibold">
91 Return to Home
92 </Link>
93 </div>
94 </div>
95 );
96 }
97
98 const totalCasualties = conflicts.reduce((sum, conflict) => sum + conflict.casualty_count, 0);
99
100 return (
101 <div className="min-h-screen bg-vmi-cream">
102 <Header
103 breadcrumbs={[
104 { label: 'Home', href: '/' },
105 { label: 'Complete Memorial Index' }
106 ]}
107 showSearch={true}
108 showIndex={false}
109 />
110
111 {/* Main Content */}
112 <main className="max-w-6xl mx-auto px-4 py-12">
113 {/* Index Header */}
114 <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-12 shadow-xl">
115 <h1 className="text-4xl font-black text-vmi-red mb-4">
116 VMI Memorial Index
117 </h1>
118 <p className="text-xl text-gray-700 mb-6">
119 Complete listing of all VMI alumni who made the ultimate sacrifice in service to their country.
120 </p>
121 <div className="flex flex-wrap gap-6 text-lg">
122 <div>
123 <span className="font-bold text-gray-700">Total Conflicts:</span>{' '}
124 <span className="text-vmi-red font-bold">{conflicts.length}</span>
125 </div>
126 <div>
127 <span className="font-bold text-gray-700">Total Lives Given:</span>{' '}
128 <span className="text-vmi-red font-bold">{totalCasualties}</span>
129 </div>
130 </div>
131 <div className="mt-6 flex justify-between items-center">
132 <button
133 onClick={toggleAll}
134 className="bg-vmi-red text-white px-6 py-2 rounded hover:bg-vmi-dark-red transition-colors font-semibold"
135 >
136 {expandedConflicts.size === conflicts.length ? 'Collapse All' : 'Expand All'}
137 </button>
138
139 {/* Sort Toggle */}
140 <div className="flex items-center gap-2">
141 <span className="text-sm text-gray-600">Sort by:</span>
142 <button
143 onClick={() => setSortBy(sortBy === 'alphabetical' ? 'class_year' : 'alphabetical')}
144 className="flex items-center bg-white border-2 border-gray-300 rounded-full p-1 shadow-sm hover:shadow-md transition-shadow"
145 >
146 <span
147 className={`px-3 py-1 rounded-full transition-all ${
148 sortBy === 'alphabetical'
149 ? 'bg-vmi-red text-white font-semibold'
150 : 'text-gray-500'
151 }`}
152 >
153 ABC
154 </span>
155 <span
156 className={`px-3 py-1 rounded-full transition-all ${
157 sortBy === 'class_year'
158 ? 'bg-vmi-red text-white font-semibold'
159 : 'text-gray-500'
160 }`}
161 >
162 &apos;42
163 </span>
164 </button>
165 </div>
166 </div>
167 </div>
168
169 {/* Conflicts and Casualties */}
170 <div className="space-y-8">
171 {conflicts.map((conflict) => (
172 <div key={conflict.id} className="bg-white border-2 border-gray-300 rounded-lg shadow-xl overflow-hidden">
173 {/* Conflict Header */}
174 <div
175 className="p-6 bg-gray-50 border-b-2 border-gray-300 cursor-pointer hover:bg-gray-100 transition-colors"
176 onClick={() => toggleConflict(conflict.id)}
177 >
178 <div className="flex justify-between items-center">
179 <div className="flex-1">
180 <h2 className="text-2xl font-bold text-vmi-red mb-1">
181 {conflict.name}
182 </h2>
183 <p className="text-gray-600">
184 {conflict.start_year === conflict.end_year
185 ? conflict.start_year
186 : `${conflict.start_year}${conflict.end_year || 'Present'}`}
187 </p>
188 </div>
189 <div className="flex items-center gap-4">
190 <div className="text-right">
191 <p className="text-3xl font-black text-vmi-red">{conflict.casualty_count}</p>
192 <p className="text-sm text-gray-600 uppercase tracking-wide">Casualties</p>
193 </div>
194 <div className="text-2xl text-gray-400">
195 {expandedConflicts.has(conflict.id) ? '−' : '+'}
196 </div>
197 </div>
198 </div>
199 </div>
200
201 {/* Casualties List */}
202 {expandedConflicts.has(conflict.id) && conflict.casualty_count > 0 && (
203 <div className="p-6">
204 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
205 {conflict.casualties.map((person) => (
206 <Link
207 key={person.id}
208 href={`/memorial/person/${person.id}`}
209 className="block p-4 border border-gray-200 rounded hover:border-vmi-gold hover:bg-vmi-light-gold transition-all duration-200 group"
210 >
211 <h3 className="font-bold text-gray-800 group-hover:text-vmi-red transition-colors flex items-center gap-2">
212 {person.rank
213 ? person.display_name.replace(person.rank + ' ', '').replace(person.rank + ', ', '')
214 : person.display_name}
215 {person.class_year && (
216 <span className="text-gray-600 font-normal">&apos;{String(person.class_year).slice(-2)}</span>
217 )}
218 {person.pdf_key && <DocumentIcon className="flex-shrink-0" />}
219 </h3>
220 {person.rank && (
221 <p className="text-gray-700 text-sm">{person.rank}</p>
222 )}
223 {person.unit && (
224 <p className="text-gray-600 text-sm italic">{person.unit}</p>
225 )}
226 {person.death_description && (
227 <p className="text-gray-600 text-sm italic mt-2 line-clamp-2">
228 {person.death_description}
229 </p>
230 )}
231 </Link>
232 ))}
233 </div>
234 </div>
235 )}
236
237 {/* No casualties message */}
238 {expandedConflicts.has(conflict.id) && conflict.casualty_count === 0 && (
239 <div className="p-6 text-center text-gray-600">
240 <p>No casualties recorded for this conflict.</p>
241 </div>
242 )}
243 </div>
244 ))}
245 </div>
246 </main>
247 </div>
248 );
249 }