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 | 6 | import { getConflicts, getPeopleByConflict, Conflict, PersonDetail } from '@/lib/api'; |
| 7 | 7 | import Header from '@/components/Header'; |
| 8 | 8 | import DocumentIcon from '@/components/DocumentIcon'; |
| 9 | +import AwardIcon from '@/components/AwardIcon'; | |
| 9 | 10 | import Pagination from '@/components/Pagination'; |
| 10 | 11 | |
| 11 | 12 | export default function ConflictPage() { |
@@ -152,6 +153,7 @@ useEffect(() => { | ||
| 152 | 153 | } |
| 153 | 154 | return name; |
| 154 | 155 | })()} |
| 156 | + {person.has_awards && <AwardIcon className="flex-shrink-0" />} | |
| 155 | 157 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} |
| 156 | 158 | </h3> |
| 157 | 159 | {person.rank && ( |
app/memorial/page.tsxmodified@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'; | ||
| 4 | 4 | import Link from 'next/link'; |
| 5 | 5 | import Header from '@/components/Header'; |
| 6 | 6 | import DocumentIcon from '@/components/DocumentIcon'; |
| 7 | +import AwardIcon from '@/components/AwardIcon'; | |
| 7 | 8 | import { PersonDetail } from '@/lib/api'; |
| 8 | 9 | |
| 9 | 10 | interface ConflictWithCasualties { |
@@ -215,6 +216,7 @@ export default function MemorialIndexPage() { | ||
| 215 | 216 | {person.class_year && ( |
| 216 | 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 | 220 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} |
| 219 | 221 | </h3> |
| 220 | 222 | {person.rank && ( |
app/memorial/person/[id]/page.tsxmodified@@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | import { useState, useEffect } from 'react'; |
| 4 | 4 | import Link from 'next/link'; |
| 5 | +import Image from 'next/image'; | |
| 5 | 6 | import { useParams } from 'next/navigation'; |
| 6 | 7 | import { getPersonDetail, PersonDetailWithContributions } from '@/lib/api'; |
| 7 | 8 | import Header from '@/components/Header'; |
@@ -169,6 +170,66 @@ export default function PersonPage() { | ||
| 169 | 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 | 233 | {/* PDF Viewer */} |
| 173 | 234 | <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl mb-12"> |
| 174 | 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 | 5 | import { searchPeople, getSearchFilters, PersonDetail, SearchFilters } from '@/lib/api'; |
| 6 | 6 | import Header from '@/components/Header'; |
| 7 | 7 | import DocumentIcon from '@/components/DocumentIcon'; |
| 8 | +import AwardIcon from '@/components/AwardIcon'; | |
| 8 | 9 | |
| 9 | 10 | export default function MemorialSearchPage() { |
| 10 | 11 | // Search state |
@@ -290,6 +291,7 @@ export default function MemorialSearchPage() { | ||
| 290 | 291 | } |
| 291 | 292 | return person.full_display_name ? name : person.display_name; |
| 292 | 293 | })()} |
| 294 | + {person.has_awards && <AwardIcon className="flex-shrink-0" />} | |
| 293 | 295 | {person.pdf_key && <DocumentIcon className="flex-shrink-0" />} |
| 294 | 296 | </h3> |
| 295 | 297 | {person.rank && ( |
app/page.tsxmodified@@ -43,14 +43,20 @@ export default function Home() { | ||
| 43 | 43 | </div> |
| 44 | 44 | {/* Navigation */} |
| 45 | 45 | <nav className="flex items-center space-x-4"> |
| 46 | - <Link | |
| 47 | - href="/memorial/search" | |
| 46 | + <Link | |
| 47 | + href="/memorial/search" | |
| 48 | 48 | className="text-vmi-gold hover:text-white transition-colors font-semibold" |
| 49 | 49 | > |
| 50 | 50 | Search Memorial |
| 51 | 51 | </Link> |
| 52 | - <Link | |
| 53 | - href="/memorial" | |
| 52 | + <Link | |
| 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 | 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 | 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 | 9 | breadcrumbs: BreadcrumbItem[]; |
| 10 | 10 | showSearch?: boolean; |
| 11 | 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 | 16 | return ( |
| 16 | 17 | <header className="bg-[#AE122A] shadow-lg"> |
| 17 | 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 | 39 | </nav> |
| 39 | 40 | |
| 40 | 41 | {/* Right Navigation */} |
| 41 | - {(showSearch || showIndex) && ( | |
| 42 | + {(showSearch || showIndex || showAwards) && ( | |
| 42 | 43 | <div className="flex items-center space-x-4"> |
| 43 | 44 | {showSearch && ( |
| 44 | - <Link | |
| 45 | - href="/memorial/search" | |
| 45 | + <Link | |
| 46 | + href="/memorial/search" | |
| 46 | 47 | className="text-[#FFD619] hover:text-white transition-colors font-semibold" |
| 47 | 48 | > |
| 48 | 49 | Search Memorial |
| 49 | 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 | 60 | {showIndex && ( |
| 52 | - <Link | |
| 53 | - href="/memorial" | |
| 61 | + <Link | |
| 62 | + href="/memorial" | |
| 54 | 63 | className="bg-[#FFD619] text-[#AE122A] px-6 py-2 rounded font-bold hover:bg-white transition-colors shadow-md" |
| 55 | 64 | > |
| 56 | 65 | View Complete Index |
lib/api.tsmodified@@ -81,6 +81,7 @@ export interface Person { | ||
| 81 | 81 | full_display_name?: string; |
| 82 | 82 | death_description?: string; |
| 83 | 83 | pdf_key?: string; |
| 84 | + has_awards?: boolean; | |
| 84 | 85 | } |
| 85 | 86 | |
| 86 | 87 | export interface PersonDetail extends Person { |
@@ -97,8 +98,47 @@ export interface PersonDetail extends Person { | ||
| 97 | 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 | 139 | export interface PersonDetailWithContributions extends PersonDetail { |
| 101 | 140 | contributions?: Contribution[]; |
| 141 | + awards?: PersonAward[]; | |
| 102 | 142 | } |
| 103 | 143 | |
| 104 | 144 | export interface PersonSearchResult { |
@@ -114,6 +154,7 @@ export interface PersonSearchResult { | ||
| 114 | 154 | conflict_name: string; |
| 115 | 155 | conflict_id: number; |
| 116 | 156 | pdf_key?: string; |
| 157 | + has_awards?: boolean; | |
| 117 | 158 | } |
| 118 | 159 | |
| 119 | 160 | export interface SearchFilters { |
@@ -249,11 +290,30 @@ export async function getPersonContributions(personId: number): Promise<Contribu | ||
| 249 | 290 | const response = await fetch( |
| 250 | 291 | `${API_BASE_URL}/memorial/persons/${personId}/contributions/` |
| 251 | 292 | ); |
| 252 | - | |
| 293 | + | |
| 253 | 294 | if (!response.ok) { |
| 254 | 295 | throw new Error('Failed to fetch contributions'); |
| 255 | 296 | } |
| 256 | - | |
| 297 | + | |
| 257 | 298 | const data = await response.json(); |
| 258 | 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