submission feature stable
- SHA
06ebec98a254b25478fe0c7122cd527ca52db1e7- Parents
-
21f6f88 - Tree
7101f16
06ebec9
06ebec98a254b25478fe0c7122cd527ca52db1e721f6f88
7101f16| Status | File | + | - |
|---|---|---|---|
| M |
app/memorial/person/[id]/page.tsx
|
34 | 7 |
| A |
components/ContributionDisplay.tsx
|
97 | 0 |
| A |
components/ContributionForm.tsx
|
297 | 0 |
| M |
lib/api.ts
|
125 | 5 |
app/memorial/person/[id]/page.tsxmodified@@ -3,14 +3,16 @@ | |||
| 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 { useParams } from 'next/navigation'; | 5 | import { useParams } from 'next/navigation'; |
| 6 | -import { getPersonDetail, PersonDetail } from '@/lib/api'; | 6 | +import { getPersonDetail, PersonDetailWithContributions } from '@/lib/api'; |
| 7 | import Header from '@/components/Header'; | 7 | import Header from '@/components/Header'; |
| 8 | +import ContributionForm from '@/components/ContributionForm'; | ||
| 9 | +import ContributionDisplay from '@/components/ContributionDisplay'; | ||
| 8 | 10 | ||
| 9 | export default function PersonPage() { | 11 | export default function PersonPage() { |
| 10 | const params = useParams(); | 12 | const params = useParams(); |
| 11 | const personId = parseInt(params.id as string); | 13 | const personId = parseInt(params.id as string); |
| 12 | 14 | ||
| 13 | - const [person, setPerson] = useState<PersonDetail | null>(null); | 15 | + const [person, setPerson] = useState<PersonDetailWithContributions | null>(null); |
| 14 | const [loading, setLoading] = useState(true); | 16 | const [loading, setLoading] = useState(true); |
| 15 | const [error, setError] = useState<string | null>(null); | 17 | const [error, setError] = useState<string | null>(null); |
| 16 | const [pdfError, setPdfError] = useState(false); | 18 | const [pdfError, setPdfError] = useState(false); |
@@ -39,7 +41,6 @@ export default function PersonPage() { | |||
| 39 | setLoadingPdf(true); | 41 | setLoadingPdf(true); |
| 40 | try { | 42 | try { |
| 41 | const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; | 43 | const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; |
| 42 | - // FIXED: Just set the URL directly - don't try to fetch and parse as JSON | ||
| 43 | setPdfUrl(`${apiUrl}/memorial/persons/${person.id}/pdf/`); | 44 | setPdfUrl(`${apiUrl}/memorial/persons/${person.id}/pdf/`); |
| 44 | } catch (err) { | 45 | } catch (err) { |
| 45 | console.error('Failed to set PDF URL:', err); | 46 | console.error('Failed to set PDF URL:', err); |
@@ -53,6 +54,16 @@ export default function PersonPage() { | |||
| 53 | fetchPdfUrl(); | 54 | fetchPdfUrl(); |
| 54 | }, [person, pdfUrl]); | 55 | }, [person, pdfUrl]); |
| 55 | 56 | ||
| 57 | + const handleContributionSuccess = async () => { | ||
| 58 | + // Reload person data to get updated contributions | ||
| 59 | + try { | ||
| 60 | + const personData = await getPersonDetail(personId); | ||
| 61 | + setPerson(personData); | ||
| 62 | + } catch (err) { | ||
| 63 | + console.error('Failed to reload person data:', err); | ||
| 64 | + } | ||
| 65 | + }; | ||
| 66 | + | ||
| 56 | if (loading) { | 67 | if (loading) { |
| 57 | return ( | 68 | return ( |
| 58 | <div className="min-h-screen bg-vmi-cream flex items-center justify-center"> | 69 | <div className="min-h-screen bg-vmi-cream flex items-center justify-center"> |
@@ -74,6 +85,10 @@ export default function PersonPage() { | |||
| 74 | ); | 85 | ); |
| 75 | } | 86 | } |
| 76 | 87 | ||
| 88 | + const displayName = person.full_display_name ? | ||
| 89 | + person.full_display_name.replace(person.rank + ' ', '').replace(person.rank + ', ', '') | ||
| 90 | + : person.display_name; | ||
| 91 | + | ||
| 77 | return ( | 92 | return ( |
| 78 | <div className="min-h-screen bg-vmi-cream"> | 93 | <div className="min-h-screen bg-vmi-cream"> |
| 79 | <Header | 94 | <Header |
@@ -89,9 +104,7 @@ export default function PersonPage() { | |||
| 89 | {/* Person Header */} | 104 | {/* Person Header */} |
| 90 | <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-12 shadow-xl"> | 105 | <div className="bg-vmi-light-gold border-2 border-vmi-gold rounded-lg p-8 mb-12 shadow-xl"> |
| 91 | <h1 className="text-4xl font-black text-vmi-red mb-2"> | 106 | <h1 className="text-4xl font-black text-vmi-red mb-2"> |
| 92 | - {person.full_display_name ? | 107 | + {displayName} |
| 93 | - person.full_display_name.replace(person.rank + ' ', '').replace(person.rank + ', ', '') | ||
| 94 | - : person.display_name} | ||
| 95 | </h1> | 108 | </h1> |
| 96 | 109 | ||
| 97 | {/* Rank and Unit subtitle */} | 110 | {/* Rank and Unit subtitle */} |
@@ -141,7 +154,7 @@ export default function PersonPage() { | |||
| 141 | )} | 154 | )} |
| 142 | 155 | ||
| 143 | {/* PDF Viewer */} | 156 | {/* PDF Viewer */} |
| 144 | - <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl"> | 157 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl mb-12"> |
| 145 | <h2 className="text-3xl font-bold mb-6 text-center text-vmi-red"> | 158 | <h2 className="text-3xl font-bold mb-6 text-center text-vmi-red"> |
| 146 | Memorial Document | 159 | Memorial Document |
| 147 | </h2> | 160 | </h2> |
@@ -197,6 +210,20 @@ export default function PersonPage() { | |||
| 197 | </div> | 210 | </div> |
| 198 | )} | 211 | )} |
| 199 | </div> | 212 | </div> |
| 213 | + | ||
| 214 | + {/* Community Contributions Section */} | ||
| 215 | + <ContributionForm | ||
| 216 | + personId={person.id} | ||
| 217 | + personName={displayName} | ||
| 218 | + onSuccess={handleContributionSuccess} | ||
| 219 | + /> | ||
| 220 | + | ||
| 221 | + {/* Display Approved Contributions */} | ||
| 222 | + {person.contributions && person.contributions.length > 0 && ( | ||
| 223 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl mt-8"> | ||
| 224 | + <ContributionDisplay contributions={person.contributions} /> | ||
| 225 | + </div> | ||
| 226 | + )} | ||
| 200 | </main> | 227 | </main> |
| 201 | </div> | 228 | </div> |
| 202 | ); | 229 | ); |
components/ContributionDisplay.tsxadded@@ -0,0 +1,97 @@ | |||
| 1 | +// components/ContributionDisplay.tsx | ||
| 2 | + | ||
| 3 | +// alias Image to shut the linter up | ||
| 4 | +import NextImage from 'next/image'; | ||
| 5 | +import { Contribution } from '@/lib/api'; | ||
| 6 | + | ||
| 7 | + | ||
| 8 | +interface ContributionDisplayProps { | ||
| 9 | + contributions: Contribution[]; | ||
| 10 | +} | ||
| 11 | + | ||
| 12 | +export default function ContributionDisplay({ contributions }: ContributionDisplayProps) { | ||
| 13 | + if (contributions.length === 0) { | ||
| 14 | + return null; | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + const formatDate = (dateString: string) => { | ||
| 18 | + const date = new Date(dateString); | ||
| 19 | + return date.toLocaleDateString('en-US', { | ||
| 20 | + year: 'numeric', | ||
| 21 | + month: 'long', | ||
| 22 | + day: 'numeric' | ||
| 23 | + }); | ||
| 24 | + }; | ||
| 25 | + | ||
| 26 | + return ( | ||
| 27 | + <div className="space-y-6"> | ||
| 28 | + <h3 className="text-xl font-bold text-vmi-red mb-4"> | ||
| 29 | + Community Contributions ({contributions.length}) | ||
| 30 | + </h3> | ||
| 31 | + | ||
| 32 | + <div className="space-y-4"> | ||
| 33 | + {contributions.map((contribution) => ( | ||
| 34 | + <div | ||
| 35 | + key={contribution.id} | ||
| 36 | + className="bg-gray-50 border border-gray-200 rounded-lg p-6" | ||
| 37 | + > | ||
| 38 | + {/* Header */} | ||
| 39 | + <div className="flex justify-between items-start mb-4"> | ||
| 40 | + <div> | ||
| 41 | + <p className="text-sm text-gray-600"> | ||
| 42 | + Contributed by <span className="font-semibold">{contribution.contributor_display}</span> | ||
| 43 | + </p> | ||
| 44 | + <p className="text-xs text-gray-500"> | ||
| 45 | + {formatDate(contribution.submitted_at)} | ||
| 46 | + </p> | ||
| 47 | + </div> | ||
| 48 | + <span className={` | ||
| 49 | + px-3 py-1 rounded-full text-xs font-semibold | ||
| 50 | + ${contribution.content_type === 'text' ? 'bg-blue-100 text-blue-700' : ''} | ||
| 51 | + ${contribution.content_type === 'image' ? 'bg-green-100 text-green-700' : ''} | ||
| 52 | + ${contribution.content_type === 'both' ? 'bg-purple-100 text-purple-700' : ''} | ||
| 53 | + `}> | ||
| 54 | + {contribution.content_type === 'text' && 'Text'} | ||
| 55 | + {contribution.content_type === 'image' && 'Image'} | ||
| 56 | + {contribution.content_type === 'both' && 'Text & Image'} | ||
| 57 | + </span> | ||
| 58 | + </div> | ||
| 59 | + | ||
| 60 | + {/* Content */} | ||
| 61 | + <div className="space-y-4"> | ||
| 62 | + {/* Text Content */} | ||
| 63 | + {contribution.content_text && ( | ||
| 64 | + <div className="prose prose-gray max-w-none"> | ||
| 65 | + <p className="text-gray-800 whitespace-pre-wrap"> | ||
| 66 | + {contribution.content_text} | ||
| 67 | + </p> | ||
| 68 | + </div> | ||
| 69 | + )} | ||
| 70 | + {/* Image Content */} | ||
| 71 | + {contribution.content_image && ( | ||
| 72 | + <div className="mt-4"> | ||
| 73 | + <div | ||
| 74 | + className="cursor-pointer hover:opacity-95 transition-opacity" | ||
| 75 | + onClick={() => window.open(contribution.content_image, '_blank')} | ||
| 76 | + > | ||
| 77 | + <NextImage | ||
| 78 | + src={contribution.content_image} | ||
| 79 | + alt="Community contribution" | ||
| 80 | + width={800} | ||
| 81 | + height={600} | ||
| 82 | + className="max-w-full h-auto rounded-lg border-2 border-gray-300" | ||
| 83 | + style={{ objectFit: 'contain' }} | ||
| 84 | + /> | ||
| 85 | + </div> | ||
| 86 | + <p className="text-xs text-gray-500 mt-2"> | ||
| 87 | + Click image to view full size | ||
| 88 | + </p> | ||
| 89 | + </div> | ||
| 90 | + )} | ||
| 91 | + </div> | ||
| 92 | + </div> | ||
| 93 | + ))} | ||
| 94 | + </div> | ||
| 95 | + </div> | ||
| 96 | + ); | ||
| 97 | +} | ||
components/ContributionForm.tsxadded@@ -0,0 +1,297 @@ | |||
| 1 | +// components/ContributionForm.tsx | ||
| 2 | +import { useState } from 'react'; | ||
| 3 | +// alias Image to shut up the linter | ||
| 4 | +import NextImage from 'next/image'; | ||
| 5 | +import { submitContribution, ContributionSubmit } from '@/lib/api'; | ||
| 6 | + | ||
| 7 | +interface ContributionFormProps { | ||
| 8 | + personId: number; | ||
| 9 | + personName: string; | ||
| 10 | + onSuccess?: () => void; | ||
| 11 | +} | ||
| 12 | + | ||
| 13 | +export default function ContributionForm({ personId, personName, onSuccess }: ContributionFormProps) { | ||
| 14 | + const [isOpen, setIsOpen] = useState(false); | ||
| 15 | + const [formData, setFormData] = useState<ContributionSubmit>({ | ||
| 16 | + contributor_email: '', | ||
| 17 | + content_type: 'text', | ||
| 18 | + content_text: '', | ||
| 19 | + }); | ||
| 20 | + const [imageFile, setImageFile] = useState<File | null>(null); | ||
| 21 | + const [imagePreview, setImagePreview] = useState<string | null>(null); | ||
| 22 | + const [submitting, setSubmitting] = useState(false); | ||
| 23 | + const [error, setError] = useState<string | null>(null); | ||
| 24 | + const [success, setSuccess] = useState(false); | ||
| 25 | + | ||
| 26 | + const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| 27 | + const file = e.target.files?.[0]; | ||
| 28 | + if (file) { | ||
| 29 | + // Validate file type | ||
| 30 | + const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; | ||
| 31 | + if (!validTypes.includes(file.type)) { | ||
| 32 | + setError('Please select a valid image file (JPEG, PNG, GIF, or WebP)'); | ||
| 33 | + return; | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + // Validate file size (10MB max) | ||
| 37 | + if (file.size > 10 * 1024 * 1024) { | ||
| 38 | + setError('Image size must be less than 10MB'); | ||
| 39 | + return; | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + setImageFile(file); | ||
| 43 | + setError(null); | ||
| 44 | + | ||
| 45 | + // Create preview | ||
| 46 | + const reader = new FileReader(); | ||
| 47 | + reader.onloadend = () => { | ||
| 48 | + setImagePreview(reader.result as string); | ||
| 49 | + }; | ||
| 50 | + reader.readAsDataURL(file); | ||
| 51 | + } | ||
| 52 | + }; | ||
| 53 | + | ||
| 54 | + const handleSubmit = async (e: React.FormEvent) => { | ||
| 55 | + e.preventDefault(); | ||
| 56 | + setError(null); | ||
| 57 | + | ||
| 58 | + // Validate required fields | ||
| 59 | + if (!formData.contributor_email) { | ||
| 60 | + setError('Please provide your email address'); | ||
| 61 | + return; | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + if (formData.content_type === 'text' && !formData.content_text) { | ||
| 65 | + setError('Please provide text content'); | ||
| 66 | + return; | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + if (formData.content_type === 'image' && !imageFile) { | ||
| 70 | + setError('Please select an image'); | ||
| 71 | + return; | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + if (formData.content_type === 'both' && (!formData.content_text || !imageFile)) { | ||
| 75 | + setError('Please provide both text and image'); | ||
| 76 | + return; | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + setSubmitting(true); | ||
| 80 | + | ||
| 81 | + try { | ||
| 82 | + const submitData: ContributionSubmit = { | ||
| 83 | + ...formData, | ||
| 84 | + content_image: imageFile || undefined, | ||
| 85 | + }; | ||
| 86 | + | ||
| 87 | + await submitContribution(personId, submitData); | ||
| 88 | + | ||
| 89 | + // Success! | ||
| 90 | + setSuccess(true); | ||
| 91 | + setFormData({ | ||
| 92 | + contributor_email: '', | ||
| 93 | + content_type: 'text', | ||
| 94 | + content_text: '', | ||
| 95 | + }); | ||
| 96 | + setImageFile(null); | ||
| 97 | + setImagePreview(null); | ||
| 98 | + | ||
| 99 | + // Call parent callback if provided | ||
| 100 | + if (onSuccess) { | ||
| 101 | + onSuccess(); | ||
| 102 | + } | ||
| 103 | + | ||
| 104 | + // Hide success message after 5 seconds | ||
| 105 | + setTimeout(() => { | ||
| 106 | + setSuccess(false); | ||
| 107 | + setIsOpen(false); | ||
| 108 | + }, 5000); | ||
| 109 | + | ||
| 110 | + } catch (err) { | ||
| 111 | + setError(err instanceof Error ? err.message : 'Failed to submit contribution'); | ||
| 112 | + } finally { | ||
| 113 | + setSubmitting(false); | ||
| 114 | + } | ||
| 115 | + }; | ||
| 116 | + | ||
| 117 | + return ( | ||
| 118 | + <div className="bg-white border-2 border-gray-300 rounded-lg p-8 shadow-xl"> | ||
| 119 | + <h2 className="text-2xl font-bold mb-4 text-vmi-red">Community Contributions</h2> | ||
| 120 | + | ||
| 121 | + {/* Toggle Button */} | ||
| 122 | + {!isOpen && ( | ||
| 123 | + <> | ||
| 124 | + <p className="text-gray-600 mb-4 italic"> | ||
| 125 | + No community contributions for {personName} yet. | ||
| 126 | + </p> | ||
| 127 | + <button | ||
| 128 | + onClick={() => setIsOpen(true)} | ||
| 129 | + className="bg-vmi-red text-white px-6 py-2 rounded hover:bg-vmi-dark-red transition-colors font-semibold" | ||
| 130 | + > | ||
| 131 | + Add Information or Image | ||
| 132 | + </button> | ||
| 133 | + </> | ||
| 134 | + )} | ||
| 135 | + | ||
| 136 | + {/* Contribution Form */} | ||
| 137 | + {isOpen && ( | ||
| 138 | + <form onSubmit={handleSubmit} className="space-y-6 mt-6"> | ||
| 139 | + {/* Success Message */} | ||
| 140 | + {success && ( | ||
| 141 | + <div className="bg-green-100 border-2 border-green-500 text-green-700 px-4 py-3 rounded"> | ||
| 142 | + <p className="font-semibold">Thank you for your contribution!</p> | ||
| 143 | + <p className="text-sm">Your submission will be reviewed before being published.</p> | ||
| 144 | + </div> | ||
| 145 | + )} | ||
| 146 | + | ||
| 147 | + {/* Error Message */} | ||
| 148 | + {error && ( | ||
| 149 | + <div className="bg-red-100 border-2 border-red-500 text-red-700 px-4 py-3 rounded"> | ||
| 150 | + <p>{error}</p> | ||
| 151 | + </div> | ||
| 152 | + )} | ||
| 153 | + | ||
| 154 | + {/* Email Field */} | ||
| 155 | + <div> | ||
| 156 | + <label htmlFor="email" className="block text-sm font-bold text-gray-700 mb-2"> | ||
| 157 | + Your Email Address * | ||
| 158 | + </label> | ||
| 159 | + <input | ||
| 160 | + type="email" | ||
| 161 | + id="email" | ||
| 162 | + value={formData.contributor_email} | ||
| 163 | + onChange={(e) => setFormData({ ...formData, contributor_email: e.target.value })} | ||
| 164 | + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-vmi-gold" | ||
| 165 | + placeholder="your.email@example.com" | ||
| 166 | + required | ||
| 167 | + /> | ||
| 168 | + <p className="text-xs text-gray-500 mt-1"> | ||
| 169 | + Your email will be partially hidden when displayed publicly | ||
| 170 | + </p> | ||
| 171 | + </div> | ||
| 172 | + | ||
| 173 | + {/* Content Type Selection */} | ||
| 174 | + <div> | ||
| 175 | + <label className="block text-sm font-bold text-gray-700 mb-2"> | ||
| 176 | + Contribution Type * | ||
| 177 | + </label> | ||
| 178 | + <div className="space-y-2"> | ||
| 179 | + <label className="flex items-center cursor-pointer"> | ||
| 180 | + <input | ||
| 181 | + type="radio" | ||
| 182 | + value="text" | ||
| 183 | + checked={formData.content_type === 'text'} | ||
| 184 | + onChange={(e) => setFormData({ ...formData, content_type: e.target.value as 'text' | 'image' | 'both' })} | ||
| 185 | + className="mr-2 text-vmi-red focus:ring-vmi-gold" | ||
| 186 | + /> | ||
| 187 | + <span>Text Information</span> | ||
| 188 | + </label> | ||
| 189 | + <label className="flex items-center cursor-pointer"> | ||
| 190 | + <input | ||
| 191 | + type="radio" | ||
| 192 | + value="image" | ||
| 193 | + checked={formData.content_type === 'image'} | ||
| 194 | + onChange={(e) => setFormData({ ...formData, content_type: e.target.value as 'text' | 'image' | 'both' })} | ||
| 195 | + className="mr-2 text-vmi-red focus:ring-vmi-gold" | ||
| 196 | + /> | ||
| 197 | + <span>Image/Photo</span> | ||
| 198 | + </label> | ||
| 199 | + <label className="flex items-center cursor-pointer"> | ||
| 200 | + <input | ||
| 201 | + type="radio" | ||
| 202 | + value="both" | ||
| 203 | + checked={formData.content_type === 'both'} | ||
| 204 | + onChange={(e) => setFormData({ ...formData, content_type: e.target.value as 'text' | 'image' | 'both' })} | ||
| 205 | + className="mr-2 text-vmi-red focus:ring-vmi-gold" | ||
| 206 | + /> | ||
| 207 | + <span>Both Text and Image</span> | ||
| 208 | + </label> | ||
| 209 | + </div> | ||
| 210 | + </div> | ||
| 211 | + | ||
| 212 | + {/* Text Content */} | ||
| 213 | + {(formData.content_type === 'text' || formData.content_type === 'both') && ( | ||
| 214 | + <div> | ||
| 215 | + <label htmlFor="text" className="block text-sm font-bold text-gray-700 mb-2"> | ||
| 216 | + Additional Information * | ||
| 217 | + </label> | ||
| 218 | + <textarea | ||
| 219 | + id="text" | ||
| 220 | + value={formData.content_text} | ||
| 221 | + onChange={(e) => setFormData({ ...formData, content_text: e.target.value })} | ||
| 222 | + rows={6} | ||
| 223 | + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-vmi-gold" | ||
| 224 | + placeholder="Share any additional information about this person's service, life, or sacrifice..." | ||
| 225 | + required={formData.content_type !== 'image'} | ||
| 226 | + /> | ||
| 227 | + <p className="text-xs text-gray-500 mt-1"> | ||
| 228 | + Please be respectful and accurate. All submissions are reviewed before publication. | ||
| 229 | + </p> | ||
| 230 | + </div> | ||
| 231 | + )} | ||
| 232 | + | ||
| 233 | + {/* Image Upload */} | ||
| 234 | + {(formData.content_type === 'image' || formData.content_type === 'both') && ( | ||
| 235 | + <div> | ||
| 236 | + <label htmlFor="image" className="block text-sm font-bold text-gray-700 mb-2"> | ||
| 237 | + Upload Image * | ||
| 238 | + </label> | ||
| 239 | + <input | ||
| 240 | + type="file" | ||
| 241 | + id="image" | ||
| 242 | + accept="image/jpeg,image/png,image/gif,image/webp" | ||
| 243 | + onChange={handleImageChange} | ||
| 244 | + className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:border-vmi-gold" | ||
| 245 | + required={formData.content_type !== 'text'} | ||
| 246 | + /> | ||
| 247 | + <p className="text-xs text-gray-500 mt-1"> | ||
| 248 | + Max 10MB. Accepted formats: JPEG, PNG, GIF, WebP | ||
| 249 | + </p> | ||
| 250 | + | ||
| 251 | + {/* Image Preview */} | ||
| 252 | + {imagePreview && ( | ||
| 253 | + <div className="mt-4"> | ||
| 254 | + <p className="text-sm font-bold text-gray-700 mb-2">Preview:</p> | ||
| 255 | + <NextImage | ||
| 256 | + src={imagePreview} | ||
| 257 | + alt="Preview" | ||
| 258 | + width={400} | ||
| 259 | + height={300} | ||
| 260 | + className="max-w-full h-auto max-h-64 border-2 border-gray-300 rounded" | ||
| 261 | + style={{ objectFit: 'contain' }} | ||
| 262 | + /> | ||
| 263 | + </div> | ||
| 264 | + )} | ||
| 265 | + </div> | ||
| 266 | + )} | ||
| 267 | + | ||
| 268 | + {/* Form Actions */} | ||
| 269 | + <div className="flex gap-4"> | ||
| 270 | + <button | ||
| 271 | + type="submit" | ||
| 272 | + disabled={submitting} | ||
| 273 | + className={`px-6 py-2 rounded font-semibold transition-colors ${ | ||
| 274 | + submitting | ||
| 275 | + ? 'bg-gray-400 text-gray-200 cursor-not-allowed' | ||
| 276 | + : 'bg-vmi-red text-white hover:bg-vmi-dark-red' | ||
| 277 | + }`} | ||
| 278 | + > | ||
| 279 | + {submitting ? 'Submitting...' : 'Submit Contribution'} | ||
| 280 | + </button> | ||
| 281 | + <button | ||
| 282 | + type="button" | ||
| 283 | + onClick={() => { | ||
| 284 | + setIsOpen(false); | ||
| 285 | + setError(null); | ||
| 286 | + setSuccess(false); | ||
| 287 | + }} | ||
| 288 | + className="px-6 py-2 bg-gray-300 text-gray-700 rounded font-semibold hover:bg-gray-400 transition-colors" | ||
| 289 | + > | ||
| 290 | + Cancel | ||
| 291 | + </button> | ||
| 292 | + </div> | ||
| 293 | + </form> | ||
| 294 | + )} | ||
| 295 | + </div> | ||
| 296 | + ); | ||
| 297 | +} | ||
lib/api.tsmodified@@ -1,8 +1,65 @@ | |||
| 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/api'; | 3 | const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; |
| 4 | -// const API_BASE_URL = 'https://web-production-6002.up.railway.app/api'; | ||
| 5 | 4 | ||
| 5 | +// CSRF token management | ||
| 6 | +let csrfToken: string | null = null; | ||
| 7 | + | ||
| 8 | +// Helper function to get CSRF token from cookies | ||
| 9 | +function getCookie(name: string): string | null { | ||
| 10 | + if (typeof document === 'undefined') return null; | ||
| 11 | + | ||
| 12 | + const value = `; ${document.cookie}`; | ||
| 13 | + const parts = value.split(`; ${name}=`); | ||
| 14 | + if (parts.length === 2) { | ||
| 15 | + return parts.pop()?.split(';').shift() || null; | ||
| 16 | + } | ||
| 17 | + return null; | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +// Get CSRF token from API or cookie | ||
| 21 | +async function getCSRFToken(): Promise<string | null> { | ||
| 22 | + if (csrfToken) return csrfToken; | ||
| 23 | + | ||
| 24 | + // First try to get from cookie | ||
| 25 | + const cookieToken = getCookie('csrftoken'); | ||
| 26 | + if (cookieToken) { | ||
| 27 | + csrfToken = cookieToken; | ||
| 28 | + return csrfToken; | ||
| 29 | + } | ||
| 30 | + | ||
| 31 | + // If no cookie, get from API | ||
| 32 | + try { | ||
| 33 | + const response = await fetch(`${API_BASE_URL}/csrf/`, { | ||
| 34 | + credentials: 'include', | ||
| 35 | + }); | ||
| 36 | + if (response.ok) { | ||
| 37 | + const data = await response.json(); | ||
| 38 | + csrfToken = data.csrfToken; | ||
| 39 | + return csrfToken; | ||
| 40 | + } | ||
| 41 | + } catch (error) { | ||
| 42 | + console.error('Failed to get CSRF token:', error); | ||
| 43 | + } | ||
| 44 | + | ||
| 45 | + return null; | ||
| 46 | +} | ||
| 47 | + | ||
| 48 | +export interface Contribution { | ||
| 49 | + id: number; | ||
| 50 | + content_type: 'text' | 'image' | 'both'; | ||
| 51 | + content_text?: string; | ||
| 52 | + content_image?: string; | ||
| 53 | + submitted_at: string; | ||
| 54 | + contributor_display: string; | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +export interface ContributionSubmit { | ||
| 58 | + contributor_email: string; | ||
| 59 | + content_type: 'text' | 'image' | 'both'; | ||
| 60 | + content_text?: string; | ||
| 61 | + content_image?: File; | ||
| 62 | +} | ||
| 6 | 63 | ||
| 7 | export interface Conflict { | 64 | export interface Conflict { |
| 8 | id: number; | 65 | id: number; |
@@ -24,14 +81,13 @@ export interface Person { | |||
| 24 | death_description?: string; | 81 | death_description?: string; |
| 25 | } | 82 | } |
| 26 | 83 | ||
| 27 | -// CHANGED: Added death_date_display?: string | ||
| 28 | export interface PersonDetail extends Person { | 84 | export interface PersonDetail extends Person { |
| 29 | first_name: string; | 85 | first_name: string; |
| 30 | middle_name: string; | 86 | middle_name: string; |
| 31 | last_name: string; | 87 | last_name: string; |
| 32 | suffix: string; | 88 | suffix: string; |
| 33 | date_of_death: string | null; | 89 | date_of_death: string | null; |
| 34 | - death_date_display?: string; // <-- ADDED THIS LINE | 90 | + death_date_display?: string; |
| 35 | death_description: string; | 91 | death_description: string; |
| 36 | conflict: number; | 92 | conflict: number; |
| 37 | conflict_name: string; | 93 | conflict_name: string; |
@@ -39,7 +95,10 @@ export interface PersonDetail extends Person { | |||
| 39 | pdf_url: string | null; | 95 | pdf_url: string | null; |
| 40 | } | 96 | } |
| 41 | 97 | ||
| 42 | -// CHANGED: Added death_date_display?: string | 98 | +export interface PersonDetailWithContributions extends PersonDetail { |
| 99 | + contributions?: Contribution[]; | ||
| 100 | +} | ||
| 101 | + | ||
| 43 | export interface PersonSearchResult { | 102 | export interface PersonSearchResult { |
| 44 | id: number; | 103 | id: number; |
| 45 | display_name: string; | 104 | display_name: string; |
@@ -48,7 +107,7 @@ export interface PersonSearchResult { | |||
| 48 | rank: string; | 107 | rank: string; |
| 49 | unit: string; | 108 | unit: string; |
| 50 | date_of_death: string | null; | 109 | date_of_death: string | null; |
| 51 | - death_date_display?: string; // <-- ADDED THIS LINE | 110 | + death_date_display?: string; |
| 52 | conflict_name: string; | 111 | conflict_name: string; |
| 53 | conflict_id: number; | 112 | conflict_id: number; |
| 54 | } | 113 | } |
@@ -130,4 +189,65 @@ export async function getSearchFilters(): Promise<SearchFilters> { | |||
| 130 | throw new Error('Failed to fetch search filters'); | 189 | throw new Error('Failed to fetch search filters'); |
| 131 | } | 190 | } |
| 132 | return response.json(); | 191 | return response.json(); |
| 192 | +} | ||
| 193 | + | ||
| 194 | +// Submit contribution with CSRF protection | ||
| 195 | +export async function submitContribution( | ||
| 196 | + personId: number, | ||
| 197 | + data: ContributionSubmit | ||
| 198 | +): Promise<Contribution> { | ||
| 199 | + const formData = new FormData(); | ||
| 200 | + formData.append('contributor_email', data.contributor_email); | ||
| 201 | + formData.append('content_type', data.content_type); | ||
| 202 | + | ||
| 203 | + if (data.content_text) { | ||
| 204 | + formData.append('content_text', data.content_text); | ||
| 205 | + } | ||
| 206 | + | ||
| 207 | + if (data.content_image) { | ||
| 208 | + formData.append('content_image', data.content_image); | ||
| 209 | + } | ||
| 210 | + | ||
| 211 | + // Get CSRF token | ||
| 212 | + const token = await getCSRFToken(); | ||
| 213 | + | ||
| 214 | + const headers: HeadersInit = {}; | ||
| 215 | + if (token) { | ||
| 216 | + headers['X-CSRFToken'] = token; | ||
| 217 | + } | ||
| 218 | + | ||
| 219 | + const response = await fetch( | ||
| 220 | + `${API_BASE_URL}/memorial/persons/${personId}/contributions/`, | ||
| 221 | + { | ||
| 222 | + method: 'POST', | ||
| 223 | + body: formData, | ||
| 224 | + headers, | ||
| 225 | + credentials: 'include', // Important for CSRF cookies | ||
| 226 | + } | ||
| 227 | + ); | ||
| 228 | + | ||
| 229 | + if (!response.ok) { | ||
| 230 | + try { | ||
| 231 | + const error = await response.json(); | ||
| 232 | + throw new Error(error.error || error.detail || 'Failed to submit contribution'); | ||
| 233 | + } catch { | ||
| 234 | + throw new Error(`Failed to submit contribution: ${response.statusText}`); | ||
| 235 | + } | ||
| 236 | + } | ||
| 237 | + | ||
| 238 | + return response.json(); | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +// Get approved contributions for a person | ||
| 242 | +export async function getPersonContributions(personId: number): Promise<Contribution[]> { | ||
| 243 | + const response = await fetch( | ||
| 244 | + `${API_BASE_URL}/memorial/persons/${personId}/contributions/` | ||
| 245 | + ); | ||
| 246 | + | ||
| 247 | + if (!response.ok) { | ||
| 248 | + throw new Error('Failed to fetch contributions'); | ||
| 249 | + } | ||
| 250 | + | ||
| 251 | + const data = await response.json(); | ||
| 252 | + return data.results || []; | ||
| 133 | } | 253 | } |