update for awards
- SHA
15fb122bcf939b677f78f41b748b004cc29b263c- Parents
-
44c9250 - Tree
32678dc
15fb122
15fb122bcf939b677f78f41b748b004cc29b263c44c9250
32678dc| Status | File | + | - |
|---|---|---|---|
| A |
app/awards/[id]/page.tsx
|
193 | 0 |
| A |
app/awards/page.tsx
|
124 | 0 |
| M |
app/memorial/conflict/[id]/page.tsx
|
2 | 0 |
| M |
app/memorial/page.tsx
|
2 | 0 |
| M |
app/memorial/person/[id]/page.tsx
|
61 | 0 |
| M |
app/memorial/search/page.tsx
|
2 | 0 |
| M |
app/page.tsx
|
10 | 4 |
| A |
components/AwardIcon.tsx
|
22 | 0 |
| M |
components/Header.tsx
|
15 | 6 |
| M |
lib/api.ts
|
62 | 2 |
| A |
public/AirForceCross.jpg
|
bin | |
| A |
public/CroixDeGuerre.jpg
|
bin | |
| A |
public/DistinguishedServiceCross.jpg
|
bin | |
| A |
public/DistinguishedServiceOrder.png
|
bin | |
| A |
public/MedailleMilitaire.jpg
|
bin | |
| A |
public/MoHAirForce.jpg
|
bin | |
| A |
public/MoHArmy.jpg
|
bin | |
| A |
public/MoHNavy.jpg
|
bin | |
| A |
public/NavyCross.jpg
|
bin | |
| A |
public/SilverStar.png
|
bin | |
| A |
public/VictoriaCross.jpg
|
bin |
app/awards/[id]/page.tsxadded@@ -0,0 +1,193 @@ | |||
| 1 | +'use client'; | ||
| 2 | + | ||
| 3 | +import { useState, useEffect } from 'react'; | ||
| 4 | +import { useParams } from 'next/navigation'; | ||
| 5 | +import Link from 'next/link'; | ||
| 6 | +import Image from 'next/image'; | ||
| 7 | +import Header from '@/components/Header'; | ||
| 8 | +import DocumentIcon from '@/components/DocumentIcon'; | ||
| 9 | +import { getAwardDetail, AwardDetail, AwardRecipient } from '@/lib/api'; | ||
| 10 | + | ||
| 11 | +export default function AwardDetailPage() { | ||
| 12 | + const params = useParams(); | ||
| 13 | + const awardId = Number(params.id); | ||
| 14 | + | ||
| 15 | + const [award, setAward] = useState<AwardDetail | null>(null); | ||
| 16 | + const [loading, setLoading] = useState(true); | ||
| 17 | + const [error, setError] = useState<string | null>(null); | ||
| 18 | + | ||
| 19 | + useEffect(() => { | ||
| 20 | + async function fetchAward() { | ||
| 21 | + try { | ||
| 22 | + const data = await getAwardDetail(awardId); | ||
| 23 | + setAward(data); | ||
| 24 | + } catch (err) { | ||
| 25 | + setError('Failed to load award details'); | ||
| 26 | + console.error(err); | ||
| 27 | + } finally { | ||
| 28 | + setLoading(false); | ||
| 29 | + } | ||
| 30 | + } | ||
| 31 | + if (awardId) { | ||
| 32 | + fetchAward(); | ||
| 33 | + } | ||
| 34 | + }, [awardId]); | ||
| 35 | + | ||
| 36 | + // Helper to get image path | ||
| 37 | + const getImagePath = (filename: string) => { | ||
| 38 | + const extensions = ['.jpg', '.png', '.jpeg', '.gif']; | ||
| 39 | + for (const ext of extensions) { | ||
| 40 | + if (filename.toLowerCase().endsWith(ext)) { | ||
| 41 | + return `/${filename}`; | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + return `/${filename}.jpg`; | ||
| 45 | + }; | ||
| 46 | + | ||
| 47 | + const breadcrumbs = [ | ||
| 48 | + { label: 'Home', href: '/' }, | ||
| 49 | + { label: 'Awards', href: '/awards' }, | ||
| 50 | + { label: award?.name || 'Loading...' } | ||
| 51 | + ]; | ||
| 52 | + | ||
| 53 | + if (loading) { | ||
| 54 | + return ( | ||
| 55 | + <div className="min-h-screen bg-vmi-cream"> | ||
| 56 | + <Header breadcrumbs={breadcrumbs} showAwards={false} /> | ||
| 57 | + <main className="max-w-6xl mx-auto px-4 py-12"> | ||
| 58 | + <p className="text-center text-gray-600">Loading award details...</p> | ||
| 59 | + </main> | ||
| 60 | + </div> | ||
| 61 | + ); | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + if (error || !award) { | ||
| 65 | + return ( | ||
| 66 | + <div className="min-h-screen bg-vmi-cream"> | ||
| 67 | + <Header breadcrumbs={breadcrumbs} showAwards={false} /> | ||
| 68 | + <main className="max-w-6xl mx-auto px-4 py-12"> | ||
| 69 | + <p className="text-center text-red-600">{error || 'Award not found'}</p> | ||
| 70 | + <div className="text-center mt-4"> | ||
| 71 | + <Link href="/awards" className="text-vmi-red hover:underline"> | ||
| 72 | + ← Back to Awards | ||
| 73 | + </Link> | ||
| 74 | + </div> | ||
| 75 | + </main> | ||
| 76 | + </div> | ||
| 77 | + ); | ||
| 78 | + } | ||
| 79 | + | ||
| 80 | + return ( | ||
| 81 | + <div className="min-h-screen bg-vmi-cream"> | ||
| 82 | + <Header breadcrumbs={breadcrumbs} showAwards={false} /> | ||
| 83 | + | ||
| 84 | + <main className="max-w-6xl mx-auto px-4 py-12"> | ||
| 85 | + {/* Award Header Section */} | ||
| 86 | + <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-8 shadow-xl"> | ||
| 87 | + <div className="flex flex-col md:flex-row items-center gap-8"> | ||
| 88 | + {/* Award Image */} | ||
| 89 | + <div className="relative w-32 h-44 flex-shrink-0"> | ||
| 90 | + <Image | ||
| 91 | + src={getImagePath(award.image_filename)} | ||
| 92 | + alt={award.name} | ||
| 93 | + fill | ||
| 94 | + className="object-contain" | ||
| 95 | + sizes="128px" | ||
| 96 | + priority | ||
| 97 | + /> | ||
| 98 | + </div> | ||
| 99 | + | ||
| 100 | + {/* Award Info */} | ||
| 101 | + <div className="text-center md:text-left"> | ||
| 102 | + <h1 className="text-3xl md:text-4xl font-black mb-4 text-vmi-red"> | ||
| 103 | + {award.name} | ||
| 104 | + </h1> | ||
| 105 | + <p className="text-gray-700 text-lg mb-4"> | ||
| 106 | + {award.short_description} | ||
| 107 | + </p> | ||
| 108 | + <p className="text-vmi-red font-bold text-xl"> | ||
| 109 | + {award.recipient_count} VMI {award.recipient_count === 1 ? 'Recipient' : 'Recipients'} | ||
| 110 | + {award.total_awards_given > award.recipient_count && ( | ||
| 111 | + <span className="text-gray-600 font-normal text-base ml-2"> | ||
| 112 | + ({award.total_awards_given} total awards) | ||
| 113 | + </span> | ||
| 114 | + )} | ||
| 115 | + </p> | ||
| 116 | + </div> | ||
| 117 | + </div> | ||
| 118 | + </div> | ||
| 119 | + | ||
| 120 | + {/* Long Description Section */} | ||
| 121 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-8 mb-8 shadow-xl"> | ||
| 122 | + <h2 className="text-2xl font-bold mb-4 text-vmi-red">About This Award</h2> | ||
| 123 | + <div className="prose max-w-none text-gray-700 whitespace-pre-line"> | ||
| 124 | + {award.long_description} | ||
| 125 | + </div> | ||
| 126 | + </div> | ||
| 127 | + | ||
| 128 | + {/* Recipients Section */} | ||
| 129 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl"> | ||
| 130 | + <h2 className="text-2xl font-bold mb-6 text-vmi-red"> | ||
| 131 | + VMI Alumni Recipients | ||
| 132 | + </h2> | ||
| 133 | + | ||
| 134 | + {award.recipients.length === 0 ? ( | ||
| 135 | + <p className="text-center text-gray-600"> | ||
| 136 | + No VMI recipients have been added yet. | ||
| 137 | + </p> | ||
| 138 | + ) : ( | ||
| 139 | + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | ||
| 140 | + {award.recipients.map((recipient: AwardRecipient) => ( | ||
| 141 | + <Link | ||
| 142 | + key={`${recipient.person_id}-${recipient.count}`} | ||
| 143 | + href={`/memorial/person/${recipient.person_id}`} | ||
| 144 | + className="block p-4 rounded-lg border-2 border-gray-200 hover:border-vmi-gold hover:bg-vmi-light-gold transition-all duration-200 group" | ||
| 145 | + > | ||
| 146 | + <div className="flex items-start justify-between"> | ||
| 147 | + <div className="flex-1"> | ||
| 148 | + <h3 className="font-bold text-gray-800 group-hover:text-vmi-red transition-colors"> | ||
| 149 | + {recipient.display_name} | ||
| 150 | + {recipient.count > 1 && ( | ||
| 151 | + <span className="ml-2 text-sm text-vmi-gold font-normal"> | ||
| 152 | + (×{recipient.count}) | ||
| 153 | + </span> | ||
| 154 | + )} | ||
| 155 | + </h3> | ||
| 156 | + {recipient.class_year && ( | ||
| 157 | + <p className="text-sm text-gray-600"> | ||
| 158 | + Class of {recipient.class_year} | ||
| 159 | + {recipient.class_letter && ` (${recipient.class_letter})`} | ||
| 160 | + </p> | ||
| 161 | + )} | ||
| 162 | + <p className="text-sm text-gray-500">{recipient.conflict_name}</p> | ||
| 163 | + {recipient.date_awarded && ( | ||
| 164 | + <p className="text-xs text-gray-400 mt-1"> | ||
| 165 | + Awarded: {new Date(recipient.date_awarded).toLocaleDateString()} | ||
| 166 | + </p> | ||
| 167 | + )} | ||
| 168 | + </div> | ||
| 169 | + {recipient.pdf_key && ( | ||
| 170 | + <DocumentIcon className="ml-2 flex-shrink-0" /> | ||
| 171 | + )} | ||
| 172 | + </div> | ||
| 173 | + {recipient.citation && ( | ||
| 174 | + <p className="mt-2 text-sm text-gray-600 line-clamp-2 italic"> | ||
| 175 | + "{recipient.citation}" | ||
| 176 | + </p> | ||
| 177 | + )} | ||
| 178 | + </Link> | ||
| 179 | + ))} | ||
| 180 | + </div> | ||
| 181 | + )} | ||
| 182 | + </div> | ||
| 183 | + | ||
| 184 | + {/* Back Link */} | ||
| 185 | + <div className="mt-8 text-center"> | ||
| 186 | + <Link href="/awards" className="text-vmi-red hover:underline font-semibold"> | ||
| 187 | + ← Back to All Awards | ||
| 188 | + </Link> | ||
| 189 | + </div> | ||
| 190 | + </main> | ||
| 191 | + </div> | ||
| 192 | + ); | ||
| 193 | +} | ||
app/awards/page.tsxadded@@ -0,0 +1,124 @@ | |||
| 1 | +'use client'; | ||
| 2 | + | ||
| 3 | +import { useState, useEffect } from 'react'; | ||
| 4 | +import Link from 'next/link'; | ||
| 5 | +import Image from 'next/image'; | ||
| 6 | +import Header from '@/components/Header'; | ||
| 7 | +import { getAwards, Award } from '@/lib/api'; | ||
| 8 | + | ||
| 9 | +export default function AwardsPage() { | ||
| 10 | + const [awards, setAwards] = useState<Award[]>([]); | ||
| 11 | + const [loading, setLoading] = useState(true); | ||
| 12 | + const [error, setError] = useState<string | null>(null); | ||
| 13 | + | ||
| 14 | + useEffect(() => { | ||
| 15 | + async function fetchAwards() { | ||
| 16 | + try { | ||
| 17 | + const data = await getAwards(); | ||
| 18 | + setAwards(data); | ||
| 19 | + } catch (err) { | ||
| 20 | + setError('Failed to load awards'); | ||
| 21 | + console.error(err); | ||
| 22 | + } finally { | ||
| 23 | + setLoading(false); | ||
| 24 | + } | ||
| 25 | + } | ||
| 26 | + fetchAwards(); | ||
| 27 | + }, []); | ||
| 28 | + | ||
| 29 | + const breadcrumbs = [ | ||
| 30 | + { label: 'Home', href: '/' }, | ||
| 31 | + { label: 'Awards for Heroism and Gallantry' } | ||
| 32 | + ]; | ||
| 33 | + | ||
| 34 | + // Helper to get image extension | ||
| 35 | + const getImagePath = (filename: string) => { | ||
| 36 | + // Check for common extensions in public folder | ||
| 37 | + const extensions = ['.jpg', '.png', '.jpeg', '.gif']; | ||
| 38 | + for (const ext of extensions) { | ||
| 39 | + // The filename in the DB might already include extension or not | ||
| 40 | + if (filename.toLowerCase().endsWith(ext)) { | ||
| 41 | + return `/${filename}`; | ||
| 42 | + } | ||
| 43 | + } | ||
| 44 | + // Default to .jpg if no extension | ||
| 45 | + return `/${filename}.jpg`; | ||
| 46 | + }; | ||
| 47 | + | ||
| 48 | + return ( | ||
| 49 | + <div className="min-h-screen bg-vmi-cream"> | ||
| 50 | + <Header breadcrumbs={breadcrumbs} showAwards={false} /> | ||
| 51 | + | ||
| 52 | + <main className="max-w-6xl mx-auto px-4 py-12"> | ||
| 53 | + {/* Title Section */} | ||
| 54 | + <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-12 shadow-xl"> | ||
| 55 | + <h1 className="text-4xl font-black text-center mb-4 text-vmi-red"> | ||
| 56 | + Awards for Heroism and Gallantry | ||
| 57 | + </h1> | ||
| 58 | + <p className="text-center text-gray-700 max-w-3xl mx-auto"> | ||
| 59 | + VMI alumni have been recognized with the nation's highest military decorations for valor | ||
| 60 | + and heroism in combat. These awards honor extraordinary acts of bravery and selfless service | ||
| 61 | + in defense of our country. | ||
| 62 | + </p> | ||
| 63 | + </div> | ||
| 64 | + | ||
| 65 | + {/* Awards Grid */} | ||
| 66 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl"> | ||
| 67 | + <h2 className="text-3xl font-bold mb-8 text-center text-vmi-red"> | ||
| 68 | + Military Decorations | ||
| 69 | + </h2> | ||
| 70 | + | ||
| 71 | + {loading && ( | ||
| 72 | + <p className="text-center text-gray-600">Loading awards...</p> | ||
| 73 | + )} | ||
| 74 | + | ||
| 75 | + {error && ( | ||
| 76 | + <p className="text-center text-red-600">{error}</p> | ||
| 77 | + )} | ||
| 78 | + | ||
| 79 | + {!loading && !error && awards.length === 0 && ( | ||
| 80 | + <p className="text-center text-gray-600"> | ||
| 81 | + No awards found. Please add some through the admin panel. | ||
| 82 | + </p> | ||
| 83 | + )} | ||
| 84 | + | ||
| 85 | + {!loading && !error && awards.length > 0 && ( | ||
| 86 | + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||
| 87 | + {awards.map((award) => ( | ||
| 88 | + <Link | ||
| 89 | + key={award.id} | ||
| 90 | + href={`/awards/${award.id}`} | ||
| 91 | + className="block p-6 rounded-lg border-2 border-gray-200 hover:border-vmi-gold hover:bg-vmi-light-gold transition-all duration-200 group" | ||
| 92 | + > | ||
| 93 | + <div className="flex flex-col items-center text-center"> | ||
| 94 | + {/* Award Image */} | ||
| 95 | + <div className="relative w-24 h-32 mb-4"> | ||
| 96 | + <Image | ||
| 97 | + src={getImagePath(award.image_filename)} | ||
| 98 | + alt={award.name} | ||
| 99 | + fill | ||
| 100 | + className="object-contain" | ||
| 101 | + sizes="96px" | ||
| 102 | + /> | ||
| 103 | + </div> | ||
| 104 | + | ||
| 105 | + {/* Award Info */} | ||
| 106 | + <h3 className="text-lg font-bold text-gray-800 group-hover:text-vmi-red transition-colors mb-2"> | ||
| 107 | + {award.name} | ||
| 108 | + </h3> | ||
| 109 | + <p className="text-gray-600 text-sm line-clamp-2 mb-3"> | ||
| 110 | + {award.short_description} | ||
| 111 | + </p> | ||
| 112 | + <p className="text-vmi-red font-bold"> | ||
| 113 | + {award.recipient_count} VMI {award.recipient_count === 1 ? 'Recipient' : 'Recipients'} | ||
| 114 | + </p> | ||
| 115 | + </div> | ||
| 116 | + </Link> | ||
| 117 | + ))} | ||
| 118 | + </div> | ||
| 119 | + )} | ||
| 120 | + </div> | ||
| 121 | + </main> | ||
| 122 | + </div> | ||
| 123 | + ); | ||
| 124 | +} | ||
app/memorial/conflict/[id]/page.tsxmodified@@ -6,6 +6,7 @@ import { useParams } from 'next/navigation'; | |||
| 6 | import { getConflicts, getPeopleByConflict, Conflict, PersonDetail } from '@/lib/api'; | 6 | import { getConflicts, getPeopleByConflict, Conflict, PersonDetail } from '@/lib/api'; |
| 7 | import Header from '@/components/Header'; | 7 | import Header from '@/components/Header'; |
| 8 | import DocumentIcon from '@/components/DocumentIcon'; | 8 | import DocumentIcon from '@/components/DocumentIcon'; |
| 9 | +import AwardIcon from '@/components/AwardIcon'; | ||
| 9 | import Pagination from '@/components/Pagination'; | 10 | import Pagination from '@/components/Pagination'; |
| 10 | 11 | ||
| 11 | export default function ConflictPage() { | 12 | export default function ConflictPage() { |
@@ -152,6 +153,7 @@ useEffect(() => { | |||
| 152 | } | 153 | } |
| 153 | return name; | 154 | return name; |
| 154 | })()} | 155 | })()} |
| 156 | + {person.has_awards && <AwardIcon className="flex-shrink-0" />} | ||
| 155 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} | 157 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} |
| 156 | </h3> | 158 | </h3> |
| 157 | {person.rank && ( | 159 | {person.rank && ( |
app/memorial/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 Header from '@/components/Header'; | 5 | import Header from '@/components/Header'; |
| 6 | import DocumentIcon from '@/components/DocumentIcon'; | 6 | import DocumentIcon from '@/components/DocumentIcon'; |
| 7 | +import AwardIcon from '@/components/AwardIcon'; | ||
| 7 | import { PersonDetail } from '@/lib/api'; | 8 | import { PersonDetail } from '@/lib/api'; |
| 8 | 9 | ||
| 9 | interface ConflictWithCasualties { | 10 | interface ConflictWithCasualties { |
@@ -215,6 +216,7 @@ export default function MemorialIndexPage() { | |||
| 215 | {person.class_year && ( | 216 | {person.class_year && ( |
| 216 | <span className="text-gray-600 font-normal">'{String(person.class_year).slice(-2)}</span> | 217 | <span className="text-gray-600 font-normal">'{String(person.class_year).slice(-2)}</span> |
| 217 | )} | 218 | )} |
| 219 | + {person.has_awards && <AwardIcon className="flex-shrink-0" />} | ||
| 218 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} | 220 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} |
| 219 | </h3> | 221 | </h3> |
| 220 | {person.rank && ( | 222 | {person.rank && ( |
app/memorial/person/[id]/page.tsxmodified@@ -2,6 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | import { useState, useEffect } from 'react'; | 3 | import { useState, useEffect } from 'react'; |
| 4 | import Link from 'next/link'; | 4 | import Link from 'next/link'; |
| 5 | +import Image from 'next/image'; | ||
| 5 | import { useParams } from 'next/navigation'; | 6 | import { useParams } from 'next/navigation'; |
| 6 | import { getPersonDetail, PersonDetailWithContributions } from '@/lib/api'; | 7 | import { getPersonDetail, PersonDetailWithContributions } from '@/lib/api'; |
| 7 | import Header from '@/components/Header'; | 8 | import Header from '@/components/Header'; |
@@ -169,6 +170,66 @@ export default function PersonPage() { | |||
| 169 | </div> | 170 | </div> |
| 170 | )} | 171 | )} |
| 171 | 172 | ||
| 173 | + {/* Awards Section */} | ||
| 174 | + {person.awards && person.awards.length > 0 && ( | ||
| 175 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-8 mb-12 shadow-xl"> | ||
| 176 | + <h2 className="text-2xl font-bold mb-6 text-vmi-red"> | ||
| 177 | + Awards for Heroism & Gallantry | ||
| 178 | + </h2> | ||
| 179 | + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> | ||
| 180 | + {person.awards.map((award) => { | ||
| 181 | + const getImagePath = (filename: string) => { | ||
| 182 | + const extensions = ['.jpg', '.png', '.jpeg', '.gif']; | ||
| 183 | + for (const ext of extensions) { | ||
| 184 | + if (filename.toLowerCase().endsWith(ext)) { | ||
| 185 | + return `/${filename}`; | ||
| 186 | + } | ||
| 187 | + } | ||
| 188 | + return `/${filename}.jpg`; | ||
| 189 | + }; | ||
| 190 | + | ||
| 191 | + return ( | ||
| 192 | + <Link | ||
| 193 | + key={award.award_id} | ||
| 194 | + href={`/awards/${award.award_id}`} | ||
| 195 | + className="flex items-center gap-4 p-4 rounded-lg border-2 border-gray-200 hover:border-vmi-gold hover:bg-vmi-light-gold transition-all duration-200 group" | ||
| 196 | + > | ||
| 197 | + <div className="relative w-16 h-20 flex-shrink-0"> | ||
| 198 | + <Image | ||
| 199 | + src={getImagePath(award.award_image_filename)} | ||
| 200 | + alt={award.award_name} | ||
| 201 | + fill | ||
| 202 | + className="object-contain" | ||
| 203 | + sizes="64px" | ||
| 204 | + /> | ||
| 205 | + </div> | ||
| 206 | + <div> | ||
| 207 | + <h3 className="font-bold text-gray-800 group-hover:text-vmi-red transition-colors"> | ||
| 208 | + {award.award_name} | ||
| 209 | + {award.count > 1 && ( | ||
| 210 | + <span className="ml-2 text-sm text-vmi-gold"> | ||
| 211 | + (×{award.count}) | ||
| 212 | + </span> | ||
| 213 | + )} | ||
| 214 | + </h3> | ||
| 215 | + {award.date_awarded && ( | ||
| 216 | + <p className="text-sm text-gray-500"> | ||
| 217 | + {new Date(award.date_awarded).toLocaleDateString()} | ||
| 218 | + </p> | ||
| 219 | + )} | ||
| 220 | + {award.citation && ( | ||
| 221 | + <p className="text-sm text-gray-600 italic line-clamp-2 mt-1"> | ||
| 222 | + "{award.citation}" | ||
| 223 | + </p> | ||
| 224 | + )} | ||
| 225 | + </div> | ||
| 226 | + </Link> | ||
| 227 | + ); | ||
| 228 | + })} | ||
| 229 | + </div> | ||
| 230 | + </div> | ||
| 231 | + )} | ||
| 232 | + | ||
| 172 | {/* PDF Viewer */} | 233 | {/* PDF Viewer */} |
| 173 | <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl mb-12"> | 234 | <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl mb-12"> |
| 174 | <h2 className="text-3xl font-bold mb-6 text-center text-vmi-red"> | 235 | <h2 className="text-3xl font-bold mb-6 text-center text-vmi-red"> |
app/memorial/search/page.tsxmodified@@ -5,6 +5,7 @@ import Link from 'next/link'; | |||
| 5 | import { searchPeople, getSearchFilters, PersonDetail, SearchFilters } from '@/lib/api'; | 5 | import { searchPeople, getSearchFilters, PersonDetail, SearchFilters } from '@/lib/api'; |
| 6 | import Header from '@/components/Header'; | 6 | import Header from '@/components/Header'; |
| 7 | import DocumentIcon from '@/components/DocumentIcon'; | 7 | import DocumentIcon from '@/components/DocumentIcon'; |
| 8 | +import AwardIcon from '@/components/AwardIcon'; | ||
| 8 | 9 | ||
| 9 | export default function MemorialSearchPage() { | 10 | export default function MemorialSearchPage() { |
| 10 | // Search state | 11 | // Search state |
@@ -290,6 +291,7 @@ export default function MemorialSearchPage() { | |||
| 290 | } | 291 | } |
| 291 | return person.full_display_name ? name : person.display_name; | 292 | return person.full_display_name ? name : person.display_name; |
| 292 | })()} | 293 | })()} |
| 294 | + {person.has_awards && <AwardIcon className="flex-shrink-0" />} | ||
| 293 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} | 295 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} |
| 294 | </h3> | 296 | </h3> |
| 295 | {person.rank && ( | 297 | {person.rank && ( |
app/page.tsxmodified@@ -43,14 +43,20 @@ export default function Home() { | |||
| 43 | </div> | 43 | </div> |
| 44 | {/* Navigation */} | 44 | {/* Navigation */} |
| 45 | <nav className="flex items-center space-x-4"> | 45 | <nav className="flex items-center space-x-4"> |
| 46 | - <Link | 46 | + <Link |
| 47 | - href="/memorial/search" | 47 | + href="/memorial/search" |
| 48 | className="text-vmi-gold hover:text-white transition-colors font-semibold" | 48 | className="text-vmi-gold hover:text-white transition-colors font-semibold" |
| 49 | > | 49 | > |
| 50 | Search Memorial | 50 | Search Memorial |
| 51 | </Link> | 51 | </Link> |
| 52 | - <Link | 52 | + <Link |
| 53 | - href="/memorial" | 53 | + href="/awards" |
| 54 | + className="bg-vmi-gold text-vmi-red px-4 py-2 rounded font-bold hover:bg-white transition-colors shadow-md text-center text-sm leading-tight" | ||
| 55 | + > | ||
| 56 | + Awards for<br />Heroism & Gallantry | ||
| 57 | + </Link> | ||
| 58 | + <Link | ||
| 59 | + href="/memorial" | ||
| 54 | className="bg-vmi-gold text-vmi-red px-6 py-2 rounded font-bold hover:bg-white transition-colors shadow-md" | 60 | className="bg-vmi-gold text-vmi-red px-6 py-2 rounded font-bold hover:bg-white transition-colors shadow-md" |
| 55 | > | 61 | > |
| 56 | View Complete Index | 62 | View Complete Index |
components/AwardIcon.tsxadded@@ -0,0 +1,22 @@ | |||
| 1 | +interface AwardIconProps { | ||
| 2 | + className?: string; | ||
| 3 | + title?: string; | ||
| 4 | +} | ||
| 5 | + | ||
| 6 | +export default function AwardIcon({ className = "", title = "Award recipient" }: AwardIconProps) { | ||
| 7 | + return ( | ||
| 8 | + <span title={title} className={`inline-block ${className}`}> | ||
| 9 | + <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#FFD619" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> | ||
| 10 | + {/* Ribbon top */} | ||
| 11 | + <rect x="7" y="2" width="10" height="4" rx="1" /> | ||
| 12 | + {/* Ribbon tails */} | ||
| 13 | + <path d="M7 6v4l2.5-2L7 6z" /> | ||
| 14 | + <path d="M17 6v4l-2.5-2L17 6z" /> | ||
| 15 | + {/* Medal circle */} | ||
| 16 | + <circle cx="12" cy="15" r="5" /> | ||
| 17 | + {/* Star in medal */} | ||
| 18 | + <path d="M12 12l1 2h2l-1.5 1.5.5 2.5-2-1.5-2 1.5.5-2.5L9 14h2z" /> | ||
| 19 | + </svg> | ||
| 20 | + </span> | ||
| 21 | + ); | ||
| 22 | +} | ||
components/Header.tsxmodified@@ -9,9 +9,10 @@ interface HeaderProps { | |||
| 9 | breadcrumbs: BreadcrumbItem[]; | 9 | breadcrumbs: BreadcrumbItem[]; |
| 10 | showSearch?: boolean; | 10 | showSearch?: boolean; |
| 11 | showIndex?: boolean; | 11 | showIndex?: boolean; |
| 12 | + showAwards?: boolean; | ||
| 12 | } | 13 | } |
| 13 | 14 | ||
| 14 | -export default function Header({ breadcrumbs, showSearch = true, showIndex = true }: HeaderProps) { | 15 | +export default function Header({ breadcrumbs, showSearch = true, showIndex = true, showAwards = true }: HeaderProps) { |
| 15 | return ( | 16 | return ( |
| 16 | <header className="bg-[#AE122A] shadow-lg"> | 17 | <header className="bg-[#AE122A] shadow-lg"> |
| 17 | <div className="max-w-6xl mx-auto px-4 py-6"> | 18 | <div className="max-w-6xl mx-auto px-4 py-6"> |
@@ -38,19 +39,27 @@ export default function Header({ breadcrumbs, showSearch = true, showIndex = tru | |||
| 38 | </nav> | 39 | </nav> |
| 39 | 40 | ||
| 40 | {/* Right Navigation */} | 41 | {/* Right Navigation */} |
| 41 | - {(showSearch || showIndex) && ( | 42 | + {(showSearch || showIndex || showAwards) && ( |
| 42 | <div className="flex items-center space-x-4"> | 43 | <div className="flex items-center space-x-4"> |
| 43 | {showSearch && ( | 44 | {showSearch && ( |
| 44 | - <Link | 45 | + <Link |
| 45 | - href="/memorial/search" | 46 | + href="/memorial/search" |
| 46 | className="text-[#FFD619] hover:text-white transition-colors font-semibold" | 47 | className="text-[#FFD619] hover:text-white transition-colors font-semibold" |
| 47 | > | 48 | > |
| 48 | Search Memorial | 49 | Search Memorial |
| 49 | </Link> | 50 | </Link> |
| 50 | )} | 51 | )} |
| 52 | + {showAwards && ( | ||
| 53 | + <Link | ||
| 54 | + href="/awards" | ||
| 55 | + className="bg-[#FFD619] text-[#AE122A] px-4 py-2 rounded font-bold hover:bg-white transition-colors shadow-md text-center text-sm leading-tight" | ||
| 56 | + > | ||
| 57 | + Awards for<br />Heroism & Gallantry | ||
| 58 | + </Link> | ||
| 59 | + )} | ||
| 51 | {showIndex && ( | 60 | {showIndex && ( |
| 52 | - <Link | 61 | + <Link |
| 53 | - href="/memorial" | 62 | + href="/memorial" |
| 54 | className="bg-[#FFD619] text-[#AE122A] px-6 py-2 rounded font-bold hover:bg-white transition-colors shadow-md" | 63 | className="bg-[#FFD619] text-[#AE122A] px-6 py-2 rounded font-bold hover:bg-white transition-colors shadow-md" |
| 55 | > | 64 | > |
| 56 | View Complete Index | 65 | View Complete Index |
lib/api.tsmodified@@ -81,6 +81,7 @@ export interface Person { | |||
| 81 | full_display_name?: string; | 81 | full_display_name?: string; |
| 82 | death_description?: string; | 82 | death_description?: string; |
| 83 | pdf_key?: string; | 83 | pdf_key?: string; |
| 84 | + has_awards?: boolean; | ||
| 84 | } | 85 | } |
| 85 | 86 | ||
| 86 | export interface PersonDetail extends Person { | 87 | export interface PersonDetail extends Person { |
@@ -97,8 +98,47 @@ export interface PersonDetail extends Person { | |||
| 97 | pdf_url: string | null; | 98 | pdf_url: string | null; |
| 98 | } | 99 | } |
| 99 | 100 | ||
| 101 | +// Award interfaces | ||
| 102 | +export interface Award { | ||
| 103 | + id: number; | ||
| 104 | + name: string; | ||
| 105 | + short_description: string; | ||
| 106 | + image_filename: string; | ||
| 107 | + recipient_count: number; | ||
| 108 | + total_awards_given: number; | ||
| 109 | + order: number; | ||
| 110 | +} | ||
| 111 | + | ||
| 112 | +export interface AwardRecipient { | ||
| 113 | + person_id: number; | ||
| 114 | + display_name: string; | ||
| 115 | + full_display_name: string; | ||
| 116 | + class_year: number | null; | ||
| 117 | + class_letter: string; | ||
| 118 | + conflict_name: string; | ||
| 119 | + pdf_key: string; | ||
| 120 | + count: number; | ||
| 121 | + date_awarded: string | null; | ||
| 122 | + citation: string; | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +export interface AwardDetail extends Award { | ||
| 126 | + long_description: string; | ||
| 127 | + recipients: AwardRecipient[]; | ||
| 128 | +} | ||
| 129 | + | ||
| 130 | +export interface PersonAward { | ||
| 131 | + award_id: number; | ||
| 132 | + award_name: string; | ||
| 133 | + award_image_filename: string; | ||
| 134 | + count: number; | ||
| 135 | + date_awarded: string | null; | ||
| 136 | + citation: string; | ||
| 137 | +} | ||
| 138 | + | ||
| 100 | export interface PersonDetailWithContributions extends PersonDetail { | 139 | export interface PersonDetailWithContributions extends PersonDetail { |
| 101 | contributions?: Contribution[]; | 140 | contributions?: Contribution[]; |
| 141 | + awards?: PersonAward[]; | ||
| 102 | } | 142 | } |
| 103 | 143 | ||
| 104 | export interface PersonSearchResult { | 144 | export interface PersonSearchResult { |
@@ -114,6 +154,7 @@ export interface PersonSearchResult { | |||
| 114 | conflict_name: string; | 154 | conflict_name: string; |
| 115 | conflict_id: number; | 155 | conflict_id: number; |
| 116 | pdf_key?: string; | 156 | pdf_key?: string; |
| 157 | + has_awards?: boolean; | ||
| 117 | } | 158 | } |
| 118 | 159 | ||
| 119 | export interface SearchFilters { | 160 | export interface SearchFilters { |
@@ -249,11 +290,30 @@ export async function getPersonContributions(personId: number): Promise<Contribu | |||
| 249 | const response = await fetch( | 290 | const response = await fetch( |
| 250 | `${API_BASE_URL}/memorial/persons/${personId}/contributions/` | 291 | `${API_BASE_URL}/memorial/persons/${personId}/contributions/` |
| 251 | ); | 292 | ); |
| 252 | - | 293 | + |
| 253 | if (!response.ok) { | 294 | if (!response.ok) { |
| 254 | throw new Error('Failed to fetch contributions'); | 295 | throw new Error('Failed to fetch contributions'); |
| 255 | } | 296 | } |
| 256 | - | 297 | + |
| 257 | const data = await response.json(); | 298 | const data = await response.json(); |
| 258 | return data.results || []; | 299 | return data.results || []; |
| 300 | +} | ||
| 301 | + | ||
| 302 | +// Fetch all awards | ||
| 303 | +export async function getAwards(): Promise<Award[]> { | ||
| 304 | + const response = await fetch(`${API_BASE_URL}/memorial/awards/`); | ||
| 305 | + if (!response.ok) { | ||
| 306 | + throw new Error('Failed to fetch awards'); | ||
| 307 | + } | ||
| 308 | + const data = await response.json(); | ||
| 309 | + return data.results || data; | ||
| 310 | +} | ||
| 311 | + | ||
| 312 | +// Fetch award detail with recipients | ||
| 313 | +export async function getAwardDetail(awardId: number): Promise<AwardDetail> { | ||
| 314 | + const response = await fetch(`${API_BASE_URL}/memorial/awards/${awardId}/`); | ||
| 315 | + if (!response.ok) { | ||
| 316 | + throw new Error('Failed to fetch award details'); | ||
| 317 | + } | ||
| 318 | + return response.json(); | ||
| 259 | } | 319 | } |
public/AirForceCross.jpgaddedpublic/CroixDeGuerre.jpgaddedpublic/DistinguishedServiceCross.jpgaddedpublic/DistinguishedServiceOrder.pngaddedpublic/MedailleMilitaire.jpgaddedpublic/MoHAirForce.jpgaddedpublic/MoHArmy.jpgaddedpublic/SilverStar.pngaddedpublic/VictoriaCross.jpgadded