search, index
- SHA
91f48cdcda7255b9edd8ff6202cd940ff497a428- Parents
-
de045ba - Tree
665a8e3
91f48cd
91f48cdcda7255b9edd8ff6202cd940ff497a428de045ba
665a8e3| Status | File | + | - |
|---|---|---|---|
| M |
app/memorial/conflict/[id]/page.tsx
|
8 | 13 |
| A |
app/memorial/page.tsx
|
208 | 0 |
| M |
app/memorial/person/[id]/page.tsx
|
14 | 20 |
| A |
app/memorial/search/page.tsx
|
319 | 0 |
| M |
app/page.tsx
|
26 | 12 |
| A |
components/Header.tsx
|
65 | 0 |
| M |
lib/api.ts
|
69 | 5 |
| M |
tailwind.config.ts
|
14 | 3 |
app/memorial/conflict/[id]/page.tsxmodified@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; | ||
| 4 | 4 | import Link from 'next/link'; |
| 5 | 5 | import { useParams } from 'next/navigation'; |
| 6 | 6 | import { getConflicts, getPeopleByConflict, Conflict, Person } from '@/lib/api'; |
| 7 | +import Header from '@/components/Header'; | |
| 7 | 8 | |
| 8 | 9 | export default function ConflictPage() { |
| 9 | 10 | const params = useParams(); |
@@ -61,18 +62,12 @@ export default function ConflictPage() { | ||
| 61 | 62 | |
| 62 | 63 | return ( |
| 63 | 64 | <div className="min-h-screen bg-vmi-cream"> |
| 64 | - {/* Header */} | |
| 65 | - <header className="bg-vmi-red shadow-lg"> | |
| 66 | - <div className="max-w-6xl mx-auto px-4 py-6"> | |
| 67 | - <nav className="flex items-center space-x-3 text-white"> | |
| 68 | - <Link href="/" className="text-vmi-gold hover:text-white transition-colors"> | |
| 69 | - Home | |
| 70 | - </Link> | |
| 71 | - <span className="text-vmi-gold">›</span> | |
| 72 | - <span className="font-semibold">{conflict.name}</span> | |
| 73 | - </nav> | |
| 74 | - </div> | |
| 75 | - </header> | |
| 65 | + <Header | |
| 66 | + breadcrumbs={[ | |
| 67 | + { label: 'Home', href: '/' }, | |
| 68 | + { label: conflict.name } | |
| 69 | + ]} | |
| 70 | + /> | |
| 76 | 71 | |
| 77 | 72 | {/* Main Content */} |
| 78 | 73 | <main className="max-w-6xl mx-auto px-4 py-12"> |
@@ -111,7 +106,7 @@ export default function ConflictPage() { | ||
| 111 | 106 | 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" |
| 112 | 107 | > |
| 113 | 108 | <h3 className="text-xl font-bold text-gray-800 group-hover:text-vmi-red transition-colors mb-2"> |
| 114 | - {person.display_name} | |
| 109 | + {person.full_display_name || person.display_name} | |
| 115 | 110 | </h3> |
| 116 | 111 | {person.rank && ( |
| 117 | 112 | <p className="text-gray-700 font-semibold">{person.rank}</p> |
app/memorial/page.tsxadded@@ -0,0 +1,208 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useEffect } from 'react'; | |
| 4 | +import Link from 'next/link'; | |
| 5 | +import Header from '@/components/Header'; | |
| 6 | + | |
| 7 | +interface Person { | |
| 8 | + id: number; | |
| 9 | + display_name: string; | |
| 10 | + rank: string; | |
| 11 | + unit: string; | |
| 12 | +} | |
| 13 | + | |
| 14 | +interface ConflictWithCasualties { | |
| 15 | + id: number; | |
| 16 | + name: string; | |
| 17 | + start_year: number; | |
| 18 | + end_year: number | null; | |
| 19 | + description: string; | |
| 20 | + casualty_count: number; | |
| 21 | + casualties: Person[]; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export default function MemorialIndexPage() { | |
| 25 | + const [conflicts, setConflicts] = useState<ConflictWithCasualties[]>([]); | |
| 26 | + const [loading, setLoading] = useState(true); | |
| 27 | + const [error, setError] = useState<string | null>(null); | |
| 28 | + const [expandedConflicts, setExpandedConflicts] = useState<Set<number>>(new Set()); | |
| 29 | + | |
| 30 | + useEffect(() => { | |
| 31 | + async function fetchData() { | |
| 32 | + try { | |
| 33 | + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; | |
| 34 | + const response = await fetch(`${apiUrl}/memorial/index/`); | |
| 35 | + | |
| 36 | + if (!response.ok) { | |
| 37 | + throw new Error('Failed to fetch memorial index'); | |
| 38 | + } | |
| 39 | + | |
| 40 | + const data = await response.json(); | |
| 41 | + setConflicts(data); | |
| 42 | + | |
| 43 | + // By default, expand conflicts with casualties | |
| 44 | + const defaultExpanded = new Set( | |
| 45 | + data.filter((c: ConflictWithCasualties) => c.casualty_count > 0).map((c: ConflictWithCasualties) => c.id) | |
| 46 | + ); | |
| 47 | + setExpandedConflicts(defaultExpanded); | |
| 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 | + }, []); | |
| 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()); | |
| 72 | + } else { | |
| 73 | + setExpandedConflicts(new Set(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"> | |
| 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 | + </div> | |
| 139 | + </div> | |
| 140 | + | |
| 141 | + {/* Conflicts and Casualties */} | |
| 142 | + <div className="space-y-8"> | |
| 143 | + {conflicts.map((conflict) => ( | |
| 144 | + <div key={conflict.id} className="bg-white border-2 border-gray-300 rounded-lg shadow-xl overflow-hidden"> | |
| 145 | + {/* Conflict Header */} | |
| 146 | + <div | |
| 147 | + className="p-6 bg-gray-50 border-b-2 border-gray-300 cursor-pointer hover:bg-gray-100 transition-colors" | |
| 148 | + onClick={() => toggleConflict(conflict.id)} | |
| 149 | + > | |
| 150 | + <div className="flex justify-between items-center"> | |
| 151 | + <div className="flex-1"> | |
| 152 | + <h2 className="text-2xl font-bold text-vmi-red mb-1"> | |
| 153 | + {conflict.name} | |
| 154 | + </h2> | |
| 155 | + <p className="text-gray-600"> | |
| 156 | + {conflict.start_year} – {conflict.end_year || 'Present'} | |
| 157 | + </p> | |
| 158 | + </div> | |
| 159 | + <div className="flex items-center gap-4"> | |
| 160 | + <div className="text-right"> | |
| 161 | + <p className="text-3xl font-black text-vmi-red">{conflict.casualty_count}</p> | |
| 162 | + <p className="text-sm text-gray-600 uppercase tracking-wide">Casualties</p> | |
| 163 | + </div> | |
| 164 | + <div className="text-2xl text-gray-400"> | |
| 165 | + {expandedConflicts.has(conflict.id) ? '−' : '+'} | |
| 166 | + </div> | |
| 167 | + </div> | |
| 168 | + </div> | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + {/* Casualties List */} | |
| 172 | + {expandedConflicts.has(conflict.id) && conflict.casualty_count > 0 && ( | |
| 173 | + <div className="p-6"> | |
| 174 | + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| 175 | + {conflict.casualties.map((person) => ( | |
| 176 | + <Link | |
| 177 | + key={person.id} | |
| 178 | + href={`/memorial/person/${person.id}`} | |
| 179 | + className="block p-4 border border-gray-200 rounded hover:border-vmi-gold hover:bg-vmi-light-gold transition-all duration-200 group" | |
| 180 | + > | |
| 181 | + <h3 className="font-bold text-gray-800 group-hover:text-vmi-red transition-colors"> | |
| 182 | + {person.display_name} | |
| 183 | + </h3> | |
| 184 | + {person.rank && ( | |
| 185 | + <p className="text-gray-700 text-sm">{person.rank}</p> | |
| 186 | + )} | |
| 187 | + {person.unit && ( | |
| 188 | + <p className="text-gray-600 text-sm italic">{person.unit}</p> | |
| 189 | + )} | |
| 190 | + </Link> | |
| 191 | + ))} | |
| 192 | + </div> | |
| 193 | + </div> | |
| 194 | + )} | |
| 195 | + | |
| 196 | + {/* No casualties message */} | |
| 197 | + {expandedConflicts.has(conflict.id) && conflict.casualty_count === 0 && ( | |
| 198 | + <div className="p-6 text-center text-gray-600"> | |
| 199 | + <p>No casualties recorded for this conflict.</p> | |
| 200 | + </div> | |
| 201 | + )} | |
| 202 | + </div> | |
| 203 | + ))} | |
| 204 | + </div> | |
| 205 | + </main> | |
| 206 | + </div> | |
| 207 | + ); | |
| 208 | +} | |
app/memorial/person/[id]/page.tsxmodified@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; | ||
| 4 | 4 | import Link from 'next/link'; |
| 5 | 5 | import { useParams } from 'next/navigation'; |
| 6 | 6 | import { getPersonDetail, PersonDetail } from '@/lib/api'; |
| 7 | +import Header from '@/components/Header'; | |
| 7 | 8 | |
| 8 | 9 | export default function PersonPage() { |
| 9 | 10 | const params = useParams(); |
@@ -55,36 +56,29 @@ export default function PersonPage() { | ||
| 55 | 56 | |
| 56 | 57 | return ( |
| 57 | 58 | <div className="min-h-screen bg-vmi-cream"> |
| 58 | - {/* Header */} | |
| 59 | - <header className="bg-vmi-red shadow-lg"> | |
| 60 | - <div className="max-w-6xl mx-auto px-4 py-6"> | |
| 61 | - <nav className="flex items-center space-x-3 text-white flex-wrap"> | |
| 62 | - <Link href="/" className="text-vmi-gold hover:text-white transition-colors"> | |
| 63 | - Home | |
| 64 | - </Link> | |
| 65 | - <span className="text-vmi-gold">›</span> | |
| 66 | - <Link | |
| 67 | - href={`/memorial/conflict/${person.conflict}`} | |
| 68 | - className="text-vmi-gold hover:text-white transition-colors" | |
| 69 | - > | |
| 70 | - {person.conflict_name} | |
| 71 | - </Link> | |
| 72 | - <span className="text-vmi-gold">›</span> | |
| 73 | - <span className="font-semibold">{person.display_name}</span> | |
| 74 | - </nav> | |
| 75 | - </div> | |
| 76 | - </header> | |
| 59 | + <Header | |
| 60 | + breadcrumbs={[ | |
| 61 | + { label: 'Home', href: '/' }, | |
| 62 | + { label: person.conflict_name, href: `/memorial/conflict/${person.conflict}` }, | |
| 63 | + { label: person.display_name } | |
| 64 | + ]} | |
| 65 | + /> | |
| 77 | 66 | |
| 78 | 67 | {/* Main Content */} |
| 79 | 68 | <main className="max-w-6xl mx-auto px-4 py-12"> |
| 80 | 69 | {/* Person Header */} |
| 81 | 70 | <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-12 shadow-xl"> |
| 82 | 71 | <h1 className="text-4xl font-black text-vmi-red mb-6"> |
| 83 | - {person.display_name} | |
| 72 | + {person.full_display_name || person.display_name} | |
| 84 | 73 | </h1> |
| 85 | 74 | |
| 86 | 75 | <div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-gray-800"> |
| 87 | 76 | <div className="space-y-3"> |
| 77 | + {person.class_year && ( | |
| 78 | + <p className="text-lg"> | |
| 79 | + <span className="font-bold text-gray-700">Class Year:</span> {person.class_year} | |
| 80 | + </p> | |
| 81 | + )} | |
| 88 | 82 | {person.rank && ( |
| 89 | 83 | <p className="text-lg"> |
| 90 | 84 | <span className="font-bold text-gray-700">Rank:</span> {person.rank} |
app/memorial/search/page.tsxadded@@ -0,0 +1,319 @@ | ||
| 1 | +'use client'; | |
| 2 | + | |
| 3 | +import { useState, useEffect } from 'react'; | |
| 4 | +import Link from 'next/link'; | |
| 5 | +import { searchPeople, getSearchFilters, PersonSearchResult, SearchFilters } from '@/lib/api'; | |
| 6 | +import Header from '@/components/Header'; | |
| 7 | + | |
| 8 | +export default function MemorialSearchPage() { | |
| 9 | + // Search state | |
| 10 | + const [searchTerm, setSearchTerm] = useState(''); | |
| 11 | + const [selectedClassYears, setSelectedClassYears] = useState<number[]>([]); | |
| 12 | + const [selectedConflicts, setSelectedConflicts] = useState<number[]>([]); | |
| 13 | + const [dateFrom, setDateFrom] = useState(''); | |
| 14 | + const [dateTo, setDateTo] = useState(''); | |
| 15 | + const [noDate, setNoDate] = useState(false); | |
| 16 | + | |
| 17 | + // Results state | |
| 18 | + const [results, setResults] = useState<PersonSearchResult[]>([]); | |
| 19 | + const [totalCount, setTotalCount] = useState(0); | |
| 20 | + const [loading, setLoading] = useState(false); | |
| 21 | + const [hasSearched, setHasSearched] = useState(false); | |
| 22 | + | |
| 23 | + // Filters state | |
| 24 | + const [filters, setFilters] = useState<SearchFilters>({ conflicts: [], class_years: [] }); | |
| 25 | + | |
| 26 | + // Load all people on mount and get filters | |
| 27 | + useEffect(() => { | |
| 28 | + async function initialize() { | |
| 29 | + try { | |
| 30 | + // Get filters | |
| 31 | + const filterData = await getSearchFilters(); | |
| 32 | + setFilters(filterData); | |
| 33 | + | |
| 34 | + // Load all people initially | |
| 35 | + const params = { | |
| 36 | + q: '', | |
| 37 | + class_year: '', | |
| 38 | + conflict: '', | |
| 39 | + date_from: '', | |
| 40 | + date_to: '', | |
| 41 | + no_date: false | |
| 42 | + }; | |
| 43 | + | |
| 44 | + const data = await searchPeople(params); | |
| 45 | + setResults(data.results); | |
| 46 | + setTotalCount(data.count); | |
| 47 | + setHasSearched(true); | |
| 48 | + } catch (err) { | |
| 49 | + console.error('Failed to initialize:', err); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + initialize(); | |
| 54 | + }, []); | |
| 55 | + | |
| 56 | + const performSearch = async () => { | |
| 57 | + setLoading(true); | |
| 58 | + setHasSearched(true); | |
| 59 | + | |
| 60 | + try { | |
| 61 | + const params = { | |
| 62 | + q: searchTerm, | |
| 63 | + class_year: selectedClassYears.join(','), | |
| 64 | + conflict: selectedConflicts.join(','), | |
| 65 | + date_from: dateFrom, | |
| 66 | + date_to: dateTo, | |
| 67 | + no_date: noDate | |
| 68 | + }; | |
| 69 | + | |
| 70 | + const data = await searchPeople(params); | |
| 71 | + setResults(data.results); | |
| 72 | + setTotalCount(data.count); | |
| 73 | + } catch (err) { | |
| 74 | + console.error('Search failed:', err); | |
| 75 | + setResults([]); | |
| 76 | + setTotalCount(0); | |
| 77 | + } finally { | |
| 78 | + setLoading(false); | |
| 79 | + } | |
| 80 | + }; | |
| 81 | + | |
| 82 | + const handleSubmit = (e: React.FormEvent) => { | |
| 83 | + e.preventDefault(); | |
| 84 | + performSearch(); | |
| 85 | + }; | |
| 86 | + | |
| 87 | + const toggleClassYear = (year: number) => { | |
| 88 | + setSelectedClassYears(prev => | |
| 89 | + prev.includes(year) | |
| 90 | + ? prev.filter(y => y !== year) | |
| 91 | + : [...prev, year] | |
| 92 | + ); | |
| 93 | + }; | |
| 94 | + | |
| 95 | + const toggleConflict = (conflictId: number) => { | |
| 96 | + setSelectedConflicts(prev => | |
| 97 | + prev.includes(conflictId) | |
| 98 | + ? prev.filter(c => c !== conflictId) | |
| 99 | + : [...prev, conflictId] | |
| 100 | + ); | |
| 101 | + }; | |
| 102 | + | |
| 103 | + const clearFilters = () => { | |
| 104 | + setSearchTerm(''); | |
| 105 | + setSelectedClassYears([]); | |
| 106 | + setSelectedConflicts([]); | |
| 107 | + setDateFrom(''); | |
| 108 | + setDateTo(''); | |
| 109 | + setNoDate(false); | |
| 110 | + }; | |
| 111 | + | |
| 112 | + return ( | |
| 113 | + <div className="min-h-screen bg-vmi-cream"> | |
| 114 | + <Header | |
| 115 | + breadcrumbs={[ | |
| 116 | + { label: 'Home', href: '/' }, | |
| 117 | + { label: 'Memorial Index', href: '/memorial' }, | |
| 118 | + { label: 'Search Memorial' } | |
| 119 | + ]} | |
| 120 | + showSearch={false} | |
| 121 | + /> | |
| 122 | + | |
| 123 | + {/* Main Content */} | |
| 124 | + <main className="max-w-7xl mx-auto px-4 py-12"> | |
| 125 | + <div className="grid grid-cols-1 lg:grid-cols-4 gap-8"> | |
| 126 | + {/* Search Form - Left Sidebar */} | |
| 127 | + <div className="lg:col-span-1"> | |
| 128 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-6 shadow-xl sticky top-6"> | |
| 129 | + <h2 className="text-2xl font-bold text-vmi-red mb-6">Search Filters</h2> | |
| 130 | + | |
| 131 | + <form onSubmit={handleSubmit} className="space-y-6"> | |
| 132 | + {/* Name Search */} | |
| 133 | + <div> | |
| 134 | + <label htmlFor="search" className="block text-sm font-bold text-gray-700 mb-2"> | |
| 135 | + Search by Name | |
| 136 | + </label> | |
| 137 | + <input | |
| 138 | + type="text" | |
| 139 | + id="search" | |
| 140 | + value={searchTerm} | |
| 141 | + onChange={(e) => setSearchTerm(e.target.value)} | |
| 142 | + placeholder="Enter name..." | |
| 143 | + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-vmi-gold" | |
| 144 | + /> | |
| 145 | + </div> | |
| 146 | + | |
| 147 | + {/* Class Year Filter */} | |
| 148 | + <div> | |
| 149 | + <label className="block text-sm font-bold text-gray-700 mb-2"> | |
| 150 | + Class Year | |
| 151 | + </label> | |
| 152 | + <div className="max-h-40 overflow-y-auto border border-gray-300 rounded p-2"> | |
| 153 | + {filters.class_years.map(year => ( | |
| 154 | + <label key={year} className="flex items-center mb-1 cursor-pointer hover:bg-gray-50"> | |
| 155 | + <input | |
| 156 | + type="checkbox" | |
| 157 | + checked={selectedClassYears.includes(year)} | |
| 158 | + onChange={() => toggleClassYear(year)} | |
| 159 | + className="mr-2 text-vmi-red focus:ring-vmi-gold" | |
| 160 | + /> | |
| 161 | + <span className="text-sm">{year}</span> | |
| 162 | + </label> | |
| 163 | + ))} | |
| 164 | + </div> | |
| 165 | + </div> | |
| 166 | + | |
| 167 | + {/* Conflict Filter */} | |
| 168 | + <div> | |
| 169 | + <label className="block text-sm font-bold text-gray-700 mb-2"> | |
| 170 | + Conflict | |
| 171 | + </label> | |
| 172 | + <div className="max-h-40 overflow-y-auto border border-gray-300 rounded p-2"> | |
| 173 | + {filters.conflicts.map(conflict => ( | |
| 174 | + <label key={conflict.id} className="flex items-center mb-1 cursor-pointer hover:bg-gray-50"> | |
| 175 | + <input | |
| 176 | + type="checkbox" | |
| 177 | + checked={selectedConflicts.includes(conflict.id)} | |
| 178 | + onChange={() => toggleConflict(conflict.id)} | |
| 179 | + className="mr-2 text-vmi-red focus:ring-vmi-gold" | |
| 180 | + /> | |
| 181 | + <span className="text-sm">{conflict.name}</span> | |
| 182 | + </label> | |
| 183 | + ))} | |
| 184 | + </div> | |
| 185 | + </div> | |
| 186 | + | |
| 187 | + {/* Date Range */} | |
| 188 | + <div> | |
| 189 | + <label className="block text-sm font-bold text-gray-700 mb-2"> | |
| 190 | + Date of Death | |
| 191 | + </label> | |
| 192 | + <div className="space-y-2"> | |
| 193 | + <input | |
| 194 | + type="date" | |
| 195 | + value={dateFrom} | |
| 196 | + onChange={(e) => setDateFrom(e.target.value)} | |
| 197 | + disabled={noDate} | |
| 198 | + placeholder="From" | |
| 199 | + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-vmi-gold disabled:bg-gray-100" | |
| 200 | + /> | |
| 201 | + <input | |
| 202 | + type="date" | |
| 203 | + value={dateTo} | |
| 204 | + onChange={(e) => setDateTo(e.target.value)} | |
| 205 | + disabled={noDate} | |
| 206 | + placeholder="To" | |
| 207 | + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-vmi-gold disabled:bg-gray-100" | |
| 208 | + /> | |
| 209 | + <label className="flex items-center cursor-pointer"> | |
| 210 | + <input | |
| 211 | + type="checkbox" | |
| 212 | + checked={noDate} | |
| 213 | + onChange={(e) => setNoDate(e.target.checked)} | |
| 214 | + className="mr-2 text-vmi-red focus:ring-vmi-gold" | |
| 215 | + /> | |
| 216 | + <span className="text-sm">No date recorded</span> | |
| 217 | + </label> | |
| 218 | + </div> | |
| 219 | + </div> | |
| 220 | + | |
| 221 | + {/* Buttons */} | |
| 222 | + <div className="space-y-2"> | |
| 223 | + <button | |
| 224 | + type="submit" | |
| 225 | + className="w-full bg-vmi-red text-white px-4 py-2 rounded font-bold hover:bg-vmi-dark-red transition-colors" | |
| 226 | + > | |
| 227 | + Search | |
| 228 | + </button> | |
| 229 | + <button | |
| 230 | + type="button" | |
| 231 | + onClick={clearFilters} | |
| 232 | + className="w-full bg-gray-300 text-gray-700 px-4 py-2 rounded font-bold hover:bg-gray-400 transition-colors" | |
| 233 | + > | |
| 234 | + Clear Filters | |
| 235 | + </button> | |
| 236 | + </div> | |
| 237 | + </form> | |
| 238 | + </div> | |
| 239 | + </div> | |
| 240 | + | |
| 241 | + {/* Results - Right Side */} | |
| 242 | + <div className="lg:col-span-3"> | |
| 243 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-6 shadow-xl"> | |
| 244 | + <div className="mb-6"> | |
| 245 | + <h1 className="text-3xl font-bold text-vmi-red mb-2">Memorial Search</h1> | |
| 246 | + {hasSearched && ( | |
| 247 | + <p className="text-gray-600"> | |
| 248 | + Found {totalCount} {totalCount === 1 ? 'person' : 'people'} | |
| 249 | + </p> | |
| 250 | + )} | |
| 251 | + </div> | |
| 252 | + | |
| 253 | + {loading ? ( | |
| 254 | + <div className="text-center py-12"> | |
| 255 | + <p className="text-gray-600">Searching...</p> | |
| 256 | + </div> | |
| 257 | + ) : results.length > 0 ? ( | |
| 258 | + <div className="space-y-4"> | |
| 259 | + {results.map((person) => ( | |
| 260 | + <Link | |
| 261 | + key={person.id} | |
| 262 | + href={`/memorial/person/${person.id}`} | |
| 263 | + 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" | |
| 264 | + > | |
| 265 | + <div className="grid grid-cols-1 md:grid-cols-3 gap-4"> | |
| 266 | + <div> | |
| 267 | + <h3 className="text-xl font-bold text-gray-800 group-hover:text-vmi-red transition-colors"> | |
| 268 | + {person.full_display_name} | |
| 269 | + </h3> | |
| 270 | + {person.rank && ( | |
| 271 | + <p className="text-gray-700">{person.rank}</p> | |
| 272 | + )} | |
| 273 | + {person.unit && ( | |
| 274 | + <p className="text-gray-600 text-sm italic">{person.unit}</p> | |
| 275 | + )} | |
| 276 | + </div> | |
| 277 | + <div> | |
| 278 | + <p className="text-gray-700 font-semibold">{person.conflict_name}</p> | |
| 279 | + {person.date_of_death && ( | |
| 280 | + <p className="text-gray-600 text-sm"> | |
| 281 | + {new Date(person.date_of_death).toLocaleDateString('en-US', { | |
| 282 | + year: 'numeric', | |
| 283 | + month: 'long', | |
| 284 | + day: 'numeric' | |
| 285 | + })} | |
| 286 | + </p> | |
| 287 | + )} | |
| 288 | + </div> | |
| 289 | + <div className="text-right"> | |
| 290 | + <span className="text-vmi-red group-hover:text-vmi-dark-red font-bold"> | |
| 291 | + View Details → | |
| 292 | + </span> | |
| 293 | + </div> | |
| 294 | + </div> | |
| 295 | + </Link> | |
| 296 | + ))} | |
| 297 | + </div> | |
| 298 | + ) : hasSearched ? ( | |
| 299 | + <div className="text-center py-12"> | |
| 300 | + <p className="text-gray-600 text-lg">No results found matching your criteria.</p> | |
| 301 | + <button | |
| 302 | + onClick={clearFilters} | |
| 303 | + className="mt-4 text-vmi-red hover:text-vmi-dark-red underline font-semibold" | |
| 304 | + > | |
| 305 | + Clear filters and try again | |
| 306 | + </button> | |
| 307 | + </div> | |
| 308 | + ) : ( | |
| 309 | + <div className="text-center py-12"> | |
| 310 | + <p className="text-gray-600 text-lg">Use the filters to search the memorial database.</p> | |
| 311 | + </div> | |
| 312 | + )} | |
| 313 | + </div> | |
| 314 | + </div> | |
| 315 | + </div> | |
| 316 | + </main> | |
| 317 | + </div> | |
| 318 | + ); | |
| 319 | +} | |
app/page.tsxmodified@@ -29,20 +29,34 @@ export default function Home() { | ||
| 29 | 29 | <div className="min-h-screen bg-vmi-cream"> |
| 30 | 30 | {/* Header */} |
| 31 | 31 | <header className="bg-vmi-red shadow-lg"> |
| 32 | - <div className="max-w-6xl mx-auto px-4 py-6 flex justify-between items-center"> | |
| 33 | - <div className="flex items-center space-x-3"> | |
| 34 | - {/* VMI Seal placeholder - replace with actual image */} | |
| 35 | - <div className="w-16 h-16 bg-vmi-gold rounded-full flex items-center justify-center text-vmi-red font-bold text-xl border-4 border-white"> | |
| 36 | - VMI | |
| 37 | - </div> | |
| 38 | - <div className="text-white"> | |
| 39 | - <div className="text-sm uppercase tracking-wide">Virginia Military Institute</div> | |
| 40 | - <div className="text-xs">Lexington, Virginia</div> | |
| 32 | + <div className="max-w-6xl mx-auto px-4 py-6"> | |
| 33 | + <div className="flex justify-between items-center"> | |
| 34 | + <div className="flex items-center space-x-3"> | |
| 35 | + {/* VMI Seal placeholder - replace with actual image */} | |
| 36 | + <div className="w-16 h-16 bg-vmi-gold rounded-full flex items-center justify-center text-vmi-red font-bold text-xl border-4 border-white"> | |
| 37 | + VMI | |
| 38 | + </div> | |
| 39 | + <div className="text-white"> | |
| 40 | + <div className="text-sm uppercase tracking-wide">Virginia Military Institute</div> | |
| 41 | + <div className="text-xs">Lexington, Virginia</div> | |
| 42 | + </div> | |
| 41 | 43 | </div> |
| 44 | + {/* Navigation */} | |
| 45 | + <nav className="flex items-center space-x-4"> | |
| 46 | + <Link | |
| 47 | + href="/memorial/search" | |
| 48 | + className="text-vmi-gold hover:text-white transition-colors font-semibold" | |
| 49 | + > | |
| 50 | + Search Memorial | |
| 51 | + </Link> | |
| 52 | + <Link | |
| 53 | + href="/memorial" | |
| 54 | + className="bg-vmi-gold text-vmi-red px-6 py-2 rounded font-bold hover:bg-white transition-colors shadow-md" | |
| 55 | + > | |
| 56 | + View Complete Index | |
| 57 | + </Link> | |
| 58 | + </nav> | |
| 42 | 59 | </div> |
| 43 | - <Link href="/memorial" className="text-vmi-gold hover:text-white transition-colors font-semibold"> | |
| 44 | - Memorial Index | |
| 45 | - </Link> | |
| 46 | 60 | </div> |
| 47 | 61 | </header> |
| 48 | 62 | |
components/Header.tsxadded@@ -0,0 +1,65 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | + | |
| 3 | +interface BreadcrumbItem { | |
| 4 | + label: string; | |
| 5 | + href?: string; | |
| 6 | +} | |
| 7 | + | |
| 8 | +interface HeaderProps { | |
| 9 | + breadcrumbs: BreadcrumbItem[]; | |
| 10 | + showSearch?: boolean; | |
| 11 | + showIndex?: boolean; | |
| 12 | +} | |
| 13 | + | |
| 14 | +export default function Header({ breadcrumbs, showSearch = true, showIndex = true }: HeaderProps) { | |
| 15 | + return ( | |
| 16 | + <header className="bg-[#AE122A] shadow-lg"> | |
| 17 | + <div className="max-w-6xl mx-auto px-4 py-6"> | |
| 18 | + <div className="flex justify-between items-center"> | |
| 19 | + {/* Breadcrumb Navigation */} | |
| 20 | + <nav className="flex items-center space-x-3 text-white flex-wrap"> | |
| 21 | + {breadcrumbs.map((item, index) => ( | |
| 22 | + <div key={index} className="flex items-center"> | |
| 23 | + {item.href ? ( | |
| 24 | + <Link | |
| 25 | + href={item.href} | |
| 26 | + className="text-[#FFD619] hover:text-white transition-colors" | |
| 27 | + > | |
| 28 | + {item.label} | |
| 29 | + </Link> | |
| 30 | + ) : ( | |
| 31 | + <span className="font-semibold">{item.label}</span> | |
| 32 | + )} | |
| 33 | + {index < breadcrumbs.length - 1 && ( | |
| 34 | + <span className="text-[#FFD619] ml-3">›</span> | |
| 35 | + )} | |
| 36 | + </div> | |
| 37 | + ))} | |
| 38 | + </nav> | |
| 39 | + | |
| 40 | + {/* Right Navigation */} | |
| 41 | + {(showSearch || showIndex) && ( | |
| 42 | + <div className="flex items-center space-x-4"> | |
| 43 | + {showSearch && ( | |
| 44 | + <Link | |
| 45 | + href="/memorial/search" | |
| 46 | + className="text-[#FFD619] hover:text-white transition-colors font-semibold" | |
| 47 | + > | |
| 48 | + Search Memorial | |
| 49 | + </Link> | |
| 50 | + )} | |
| 51 | + {showIndex && ( | |
| 52 | + <Link | |
| 53 | + href="/memorial" | |
| 54 | + className="bg-[#FFD619] text-[#AE122A] px-6 py-2 rounded font-bold hover:bg-white transition-colors shadow-md" | |
| 55 | + > | |
| 56 | + View Complete Index | |
| 57 | + </Link> | |
| 58 | + )} | |
| 59 | + </div> | |
| 60 | + )} | |
| 61 | + </div> | |
| 62 | + </div> | |
| 63 | + </header> | |
| 64 | + ); | |
| 65 | +} | |
lib/api.tsmodified@@ -1,7 +1,7 @@ | ||
| 1 | 1 | // API configuration for the VMI Memorial frontend |
| 2 | 2 | |
| 3 | -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; | |
| 4 | -// const API_BASE_URL = 'https://web-production-6002.up.railway.app'; | |
| 3 | +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; | |
| 4 | +// const API_BASE_URL = 'https://web-production-6002.up.railway.app/api'; | |
| 5 | 5 | |
| 6 | 6 | |
| 7 | 7 | export interface Conflict { |
@@ -19,6 +19,8 @@ export interface Person { | ||
| 19 | 19 | display_name: string; |
| 20 | 20 | rank: string; |
| 21 | 21 | unit: string; |
| 22 | + class_year?: number; | |
| 23 | + full_display_name?: string; | |
| 22 | 24 | } |
| 23 | 25 | |
| 24 | 26 | export interface PersonDetail extends Person { |
@@ -33,9 +35,35 @@ export interface PersonDetail extends Person { | ||
| 33 | 35 | pdf_url: string | null; |
| 34 | 36 | } |
| 35 | 37 | |
| 38 | +export interface PersonSearchResult { | |
| 39 | + id: number; | |
| 40 | + display_name: string; | |
| 41 | + full_display_name: string; | |
| 42 | + class_year: number | null; | |
| 43 | + rank: string; | |
| 44 | + unit: string; | |
| 45 | + date_of_death: string | null; | |
| 46 | + conflict_name: string; | |
| 47 | + conflict_id: number; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export interface SearchFilters { | |
| 51 | + conflicts: Conflict[]; | |
| 52 | + class_years: number[]; | |
| 53 | +} | |
| 54 | + | |
| 55 | +export interface SearchParams { | |
| 56 | + q?: string; | |
| 57 | + class_year?: string; | |
| 58 | + conflict?: string; | |
| 59 | + date_from?: string; | |
| 60 | + date_to?: string; | |
| 61 | + no_date?: boolean; | |
| 62 | +} | |
| 63 | + | |
| 36 | 64 | // Fetch all conflicts |
| 37 | 65 | export async function getConflicts(): Promise<Conflict[]> { |
| 38 | - const response = await fetch(`${API_BASE_URL}/api/memorial/conflicts/`); | |
| 66 | + const response = await fetch(`${API_BASE_URL}/memorial/conflicts/`); | |
| 39 | 67 | if (!response.ok) { |
| 40 | 68 | throw new Error('Failed to fetch conflicts'); |
| 41 | 69 | } |
@@ -45,7 +73,7 @@ export async function getConflicts(): Promise<Conflict[]> { | ||
| 45 | 73 | |
| 46 | 74 | // Fetch people by conflict |
| 47 | 75 | export async function getPeopleByConflict(conflictId: number): Promise<Person[]> { |
| 48 | - const response = await fetch(`${API_BASE_URL}/api/memorial/persons/?conflict=${conflictId}`); | |
| 76 | + const response = await fetch(`${API_BASE_URL}/memorial/persons/?conflict=${conflictId}`); | |
| 49 | 77 | if (!response.ok) { |
| 50 | 78 | throw new Error('Failed to fetch people'); |
| 51 | 79 | } |
@@ -55,9 +83,45 @@ export async function getPeopleByConflict(conflictId: number): Promise<Person[]> | ||
| 55 | 83 | |
| 56 | 84 | // Fetch person details |
| 57 | 85 | export async function getPersonDetail(personId: number): Promise<PersonDetail> { |
| 58 | - const response = await fetch(`${API_BASE_URL}/api/memorial/persons/${personId}/`); | |
| 86 | + const response = await fetch(`${API_BASE_URL}/memorial/persons/${personId}/`); | |
| 59 | 87 | if (!response.ok) { |
| 60 | 88 | throw new Error('Failed to fetch person details'); |
| 61 | 89 | } |
| 62 | 90 | return response.json(); |
| 91 | +} | |
| 92 | + | |
| 93 | +// Fetch memorial index (all conflicts with casualties) | |
| 94 | +export async function getMemorialIndex(): Promise<Conflict[]> { | |
| 95 | + const response = await fetch(`${API_BASE_URL}/memorial/index/`); | |
| 96 | + if (!response.ok) { | |
| 97 | + throw new Error('Failed to fetch memorial index'); | |
| 98 | + } | |
| 99 | + return response.json(); | |
| 100 | +} | |
| 101 | + | |
| 102 | +// Search people with filters | |
| 103 | +export async function searchPeople(params: SearchParams): Promise<{ count: number; results: PersonSearchResult[] }> { | |
| 104 | + const queryParams = new URLSearchParams(); | |
| 105 | + | |
| 106 | + if (params.q) queryParams.append('q', params.q); | |
| 107 | + if (params.class_year) queryParams.append('class_year', params.class_year); | |
| 108 | + if (params.conflict) queryParams.append('conflict', params.conflict); | |
| 109 | + if (params.date_from) queryParams.append('date_from', params.date_from); | |
| 110 | + if (params.date_to) queryParams.append('date_to', params.date_to); | |
| 111 | + if (params.no_date !== undefined) queryParams.append('no_date', params.no_date.toString()); | |
| 112 | + | |
| 113 | + const response = await fetch(`${API_BASE_URL}/memorial/persons/search/?${queryParams.toString()}`); | |
| 114 | + if (!response.ok) { | |
| 115 | + throw new Error('Failed to search people'); | |
| 116 | + } | |
| 117 | + return response.json(); | |
| 118 | +} | |
| 119 | + | |
| 120 | +// Get available search filters | |
| 121 | +export async function getSearchFilters(): Promise<SearchFilters> { | |
| 122 | + const response = await fetch(`${API_BASE_URL}/memorial/search-filters/`); | |
| 123 | + if (!response.ok) { | |
| 124 | + throw new Error('Failed to fetch search filters'); | |
| 125 | + } | |
| 126 | + return response.json(); | |
| 63 | 127 | } |
tailwind.config.tsmodified@@ -9,11 +9,22 @@ const config: Config = { | ||
| 9 | 9 | theme: { |
| 10 | 10 | extend: { |
| 11 | 11 | colors: { |
| 12 | - background: "var(--background)", | |
| 13 | - foreground: "var(--foreground)", | |
| 12 | + 'vmi-red': '#AE122A', | |
| 13 | + 'vmi-gold': '#FFD619', | |
| 14 | + 'vmi-dark-red': '#8A0E22', | |
| 15 | + 'vmi-light-gold': '#FFF3B8', | |
| 16 | + 'vmi-cream': '#d7d4c9', | |
| 17 | + 'vmi-black': '#1A1A1A', | |
| 18 | + 'vmi-gray': '#4A4A4A', | |
| 19 | + 'vmi-light-gray': '#F5F5F5', | |
| 20 | + }, | |
| 21 | + fontFamily: { | |
| 22 | + 'serif': ['Crimson Text', 'Georgia', 'serif'], | |
| 23 | + 'display': ['Playfair Display', 'Georgia', 'serif'], | |
| 14 | 24 | }, |
| 15 | 25 | }, |
| 16 | 26 | }, |
| 17 | 27 | plugins: [], |
| 18 | 28 | }; |
| 19 | -export default config; | |
| 29 | + | |
| 30 | +export default config; | |