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 | import Link from 'next/link'; | 4 | import Link from 'next/link'; |
| 5 | import { useParams } from 'next/navigation'; | 5 | import { useParams } from 'next/navigation'; |
| 6 | import { getConflicts, getPeopleByConflict, Conflict, Person } from '@/lib/api'; | 6 | import { getConflicts, getPeopleByConflict, Conflict, Person } from '@/lib/api'; |
| 7 | +import Header from '@/components/Header'; | ||
| 7 | 8 | ||
| 8 | export default function ConflictPage() { | 9 | export default function ConflictPage() { |
| 9 | const params = useParams(); | 10 | const params = useParams(); |
@@ -61,18 +62,12 @@ export default function ConflictPage() { | |||
| 61 | 62 | ||
| 62 | return ( | 63 | return ( |
| 63 | <div className="min-h-screen bg-vmi-cream"> | 64 | <div className="min-h-screen bg-vmi-cream"> |
| 64 | - {/* Header */} | 65 | + <Header |
| 65 | - <header className="bg-vmi-red shadow-lg"> | 66 | + breadcrumbs={[ |
| 66 | - <div className="max-w-6xl mx-auto px-4 py-6"> | 67 | + { label: 'Home', href: '/' }, |
| 67 | - <nav className="flex items-center space-x-3 text-white"> | 68 | + { label: conflict.name } |
| 68 | - <Link href="/" className="text-vmi-gold hover:text-white transition-colors"> | 69 | + ]} |
| 69 | - Home | 70 | + /> |
| 70 | - </Link> | ||
| 71 | - <span className="text-vmi-gold">›</span> | ||
| 72 | - <span className="font-semibold">{conflict.name}</span> | ||
| 73 | - </nav> | ||
| 74 | - </div> | ||
| 75 | - </header> | ||
| 76 | 71 | ||
| 77 | {/* Main Content */} | 72 | {/* Main Content */} |
| 78 | <main className="max-w-6xl mx-auto px-4 py-12"> | 73 | <main className="max-w-6xl mx-auto px-4 py-12"> |
@@ -111,7 +106,7 @@ export default function ConflictPage() { | |||
| 111 | 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" | 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 | <h3 className="text-xl font-bold text-gray-800 group-hover:text-vmi-red transition-colors mb-2"> | 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 | </h3> | 110 | </h3> |
| 116 | {person.rank && ( | 111 | {person.rank && ( |
| 117 | <p className="text-gray-700 font-semibold">{person.rank}</p> | 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 | import Link from 'next/link'; | 4 | import Link from 'next/link'; |
| 5 | import { useParams } from 'next/navigation'; | 5 | import { useParams } from 'next/navigation'; |
| 6 | import { getPersonDetail, PersonDetail } from '@/lib/api'; | 6 | import { getPersonDetail, PersonDetail } from '@/lib/api'; |
| 7 | +import Header from '@/components/Header'; | ||
| 7 | 8 | ||
| 8 | export default function PersonPage() { | 9 | export default function PersonPage() { |
| 9 | const params = useParams(); | 10 | const params = useParams(); |
@@ -55,36 +56,29 @@ export default function PersonPage() { | |||
| 55 | 56 | ||
| 56 | return ( | 57 | return ( |
| 57 | <div className="min-h-screen bg-vmi-cream"> | 58 | <div className="min-h-screen bg-vmi-cream"> |
| 58 | - {/* Header */} | 59 | + <Header |
| 59 | - <header className="bg-vmi-red shadow-lg"> | 60 | + breadcrumbs={[ |
| 60 | - <div className="max-w-6xl mx-auto px-4 py-6"> | 61 | + { label: 'Home', href: '/' }, |
| 61 | - <nav className="flex items-center space-x-3 text-white flex-wrap"> | 62 | + { label: person.conflict_name, href: `/memorial/conflict/${person.conflict}` }, |
| 62 | - <Link href="/" className="text-vmi-gold hover:text-white transition-colors"> | 63 | + { label: person.display_name } |
| 63 | - Home | 64 | + ]} |
| 64 | - </Link> | 65 | + /> |
| 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> | ||
| 77 | 66 | ||
| 78 | {/* Main Content */} | 67 | {/* Main Content */} |
| 79 | <main className="max-w-6xl mx-auto px-4 py-12"> | 68 | <main className="max-w-6xl mx-auto px-4 py-12"> |
| 80 | {/* Person Header */} | 69 | {/* Person Header */} |
| 81 | <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-12 shadow-xl"> | 70 | <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-12 shadow-xl"> |
| 82 | <h1 className="text-4xl font-black text-vmi-red mb-6"> | 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 | </h1> | 73 | </h1> |
| 85 | 74 | ||
| 86 | <div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-gray-800"> | 75 | <div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-gray-800"> |
| 87 | <div className="space-y-3"> | 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 | {person.rank && ( | 82 | {person.rank && ( |
| 89 | <p className="text-lg"> | 83 | <p className="text-lg"> |
| 90 | <span className="font-bold text-gray-700">Rank:</span> {person.rank} | 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 | <div className="min-h-screen bg-vmi-cream"> | 29 | <div className="min-h-screen bg-vmi-cream"> |
| 30 | {/* Header */} | 30 | {/* Header */} |
| 31 | <header className="bg-vmi-red shadow-lg"> | 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"> | 32 | + <div className="max-w-6xl mx-auto px-4 py-6"> |
| 33 | - <div className="flex items-center space-x-3"> | 33 | + <div className="flex justify-between items-center"> |
| 34 | - {/* VMI Seal placeholder - replace with actual image */} | 34 | + <div className="flex items-center space-x-3"> |
| 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"> | 35 | + {/* VMI Seal placeholder - replace with actual image */} |
| 36 | - VMI | 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 | - </div> | 37 | + VMI |
| 38 | - <div className="text-white"> | 38 | + </div> |
| 39 | - <div className="text-sm uppercase tracking-wide">Virginia Military Institute</div> | 39 | + <div className="text-white"> |
| 40 | - <div className="text-xs">Lexington, Virginia</div> | 40 | + <div className="text-sm uppercase tracking-wide">Virginia Military Institute</div> |
| 41 | + <div className="text-xs">Lexington, Virginia</div> | ||
| 42 | + </div> | ||
| 41 | </div> | 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 | </div> | 59 | </div> |
| 43 | - <Link href="/memorial" className="text-vmi-gold hover:text-white transition-colors font-semibold"> | ||
| 44 | - Memorial Index | ||
| 45 | - </Link> | ||
| 46 | </div> | 60 | </div> |
| 47 | </header> | 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 | // API configuration for the VMI Memorial frontend | 1 | // API configuration for the VMI Memorial frontend |
| 2 | 2 | ||
| 3 | -const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'; | 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'; | 4 | +// const API_BASE_URL = 'https://web-production-6002.up.railway.app/api'; |
| 5 | 5 | ||
| 6 | 6 | ||
| 7 | export interface Conflict { | 7 | export interface Conflict { |
@@ -19,6 +19,8 @@ export interface Person { | |||
| 19 | display_name: string; | 19 | display_name: string; |
| 20 | rank: string; | 20 | rank: string; |
| 21 | unit: string; | 21 | unit: string; |
| 22 | + class_year?: number; | ||
| 23 | + full_display_name?: string; | ||
| 22 | } | 24 | } |
| 23 | 25 | ||
| 24 | export interface PersonDetail extends Person { | 26 | export interface PersonDetail extends Person { |
@@ -33,9 +35,35 @@ export interface PersonDetail extends Person { | |||
| 33 | pdf_url: string | null; | 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 | // Fetch all conflicts | 64 | // Fetch all conflicts |
| 37 | export async function getConflicts(): Promise<Conflict[]> { | 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 | if (!response.ok) { | 67 | if (!response.ok) { |
| 40 | throw new Error('Failed to fetch conflicts'); | 68 | throw new Error('Failed to fetch conflicts'); |
| 41 | } | 69 | } |
@@ -45,7 +73,7 @@ export async function getConflicts(): Promise<Conflict[]> { | |||
| 45 | 73 | ||
| 46 | // Fetch people by conflict | 74 | // Fetch people by conflict |
| 47 | export async function getPeopleByConflict(conflictId: number): Promise<Person[]> { | 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 | if (!response.ok) { | 77 | if (!response.ok) { |
| 50 | throw new Error('Failed to fetch people'); | 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 | // Fetch person details | 84 | // Fetch person details |
| 57 | export async function getPersonDetail(personId: number): Promise<PersonDetail> { | 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 | if (!response.ok) { | 87 | if (!response.ok) { |
| 60 | throw new Error('Failed to fetch person details'); | 88 | throw new Error('Failed to fetch person details'); |
| 61 | } | 89 | } |
| 62 | return response.json(); | 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 | theme: { | 9 | theme: { |
| 10 | extend: { | 10 | extend: { |
| 11 | colors: { | 11 | colors: { |
| 12 | - background: "var(--background)", | 12 | + 'vmi-red': '#AE122A', |
| 13 | - foreground: "var(--foreground)", | 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 | plugins: [], | 27 | plugins: [], |
| 18 | }; | 28 | }; |
| 19 | -export default config; | 29 | + |
| 30 | +export default config; | ||