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