feat: prepare project for production release

This commit is contained in:
mark 2025-06-02 09:18:01 +08:00
parent 0d59240818
commit 062ae9315a
30 changed files with 6446 additions and 1928 deletions

2
.env
View File

@ -1 +1 @@
VITE_API_URL=http://127.0.0.1:8000
VITE_API_URL=https://migrants.staging.anss.au

View File

@ -7,10 +7,10 @@ npm run build
echo "Copying React build files to Laravel public folder..."
# Copy index.html to public root
cp dist/index.html /home/mark/nun/public/index.html
cp dist/index.html /home/mark/migrants/public/index.html
# Copy assets folder contents to public/assets folder
cp -r dist/assets/* /home/mark/nun/public/assets/
cp -r dist/assets/* /home/mark/migrants/public/assets/
echo "Deployment complete!"

4512
package-lock.json generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 KiB

View File

@ -10,7 +10,6 @@ import MigrantProfilePage from "./pages/MigrantProfilePage";
// Admin Components
import LoginPage from "./components/admin/LoginPage";
import RegisterPage from "./components/admin/Register";
import AdminDashboardPage from "./components/admin/AdminDashboard";
import Migrants from "./components/admin/Migrants";
import AddMigrantPage from "./components/admin/AddMigrant";
@ -125,7 +124,6 @@ function App() {
{/* Auth Routes */}
<Route path="/login" element={<PublicRoute><LoginPage /></PublicRoute>} />
<Route path="/register" element={<PublicRoute><RegisterPage /></PublicRoute>} />
{/* Admin Protected Routes */}
<Route path="/admin" element={<ProtectedRoute><AdminDashboardPage /></ProtectedRoute>} />

View File

@ -1,231 +0,0 @@
"use client"
import { useState } from "react"
import { motion } from "framer-motion"
import { Eye, EyeOff, Lock, Mail } from "lucide-react"
import { Link, useNavigate } from "react-router-dom"
import apiService from "@/services/apiService"
export default function SimpleForm() {
const [error, setError] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [name, setName] = useState("")
const navigate = useNavigate()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError("")
try {
const response = await apiService.register({
name,
email,
password,
})
console.log("Response:", response.data)
alert("Registration successful!")
setName("")
setEmail("")
setPassword("")
} catch (error: any) {
console.error("Error submitting form:", error)
if (error.response && error.response.data && error.response.data.message) {
setError(error.response.data.message)
} else {
setError("Registration failed. Please check your input and try again.")
}
} finally {
setIsLoading(false)
navigate("/login")
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-[#E8DCCA] bg-opacity-30">
<div className="absolute inset-0 overflow-hidden z-0">
<motion.div
className="absolute inset-0 bg-[url('/italian-migrants-historical.jpg')] bg-cover bg-center"
initial={{ opacity: 0 }}
animate={{ opacity: 0.15 }}
transition={{ duration: 1.5 }}
/>
<div className="absolute inset-0 bg-gradient-to-b from-[#9B2335]/20 to-[#01796F]/20" />
</div>
<motion.div
className="max-w-md w-full mx-4 bg-white rounded-lg shadow-xl overflow-hidden z-10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<div className="h-2 bg-gradient-to-r from-[#9B2335] via-[#E8DCCA] to-[#01796F]" />
<div className="px-8 pt-8 pb-6">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3, duration: 0.6 }}
className="text-center"
>
<h2 className="text-2xl font-serif font-bold text-[#1A2A57]">Admin Access</h2>
<p className="text-gray-600 mt-1 italic">
Northern Territory Italian Migration History
</p>
</motion.div>
</div>
<motion.div
className="px-8 pb-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5, duration: 0.6 }}
>
{error && (
<motion.div
className="mb-4 p-3 bg-red-50 border-l-4 border-[#9B2335] text-[#9B2335]"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
>
{error}
</motion.div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
placeholder="John Doe"
required
/>
</div>
</div>
<div className="mb-6">
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Mail className="h-5 w-5 text-gray-400" />
</div>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
placeholder="admin@example.com"
required
/>
</div>
</div>
<div className="mb-6">
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-gray-400" />
</div>
<input
id="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full pl-10 pr-10 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-[#01796F] focus:border-[#01796F]"
placeholder="••••••••"
required
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-gray-400 hover:text-gray-500 focus:outline-none"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
</div>
<motion.button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-white bg-[#9B2335] hover:bg-[#9B2335]/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-[#9B2335] disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isLoading ? (
<div className="flex items-center">
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Signing in...
</div>
) : (
"Sign in"
)}
</motion.button>
</form>
<div className="mt-6 flex items-center justify-center">
<div className="h-px bg-gray-300 w-full" />
<span className="px-2 text-sm text-gray-500">or</span>
<div className="h-px bg-gray-300 w-full" />
</div>
<div className="mt-6 text-center">
<Link to="/" className="text-sm font-medium text-[#1A2A57] hover:text-[#1A2A57]/80">
Return to public site
</Link>
</div>
</motion.div>
</motion.div>
<motion.div
className="absolute bottom-4 text-center text-xs text-gray-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }}
>
© {new Date().getFullYear()} Northern Territory Italian Migration History Project
</motion.div>
</div>
)
}

View File

@ -14,6 +14,8 @@ import apiService from "@/services/apiService"
import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
import type { User } from "@/types/api"
import { UserUpdateSchema } from "@/schemas/userSchema"
import { z } from "zod"
export default function UserCreate() {
const navigate = useNavigate()
@ -26,12 +28,13 @@ export default function UserCreate() {
password: '',
password_confirmation: '',
});
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
useEffect(() => {
async function loadUser() {
try {
const currentUser = await apiService.fetchCurrentUser()
console.log("Setting component - Fetched user:", currentUser)
// Fetch user data and update state
setUser(currentUser)
// Pre-fill the form with current user data if available
@ -43,7 +46,7 @@ export default function UserCreate() {
}))
}
} catch (error) {
console.error("Failed to fetch user info:", error)
// Handle error when fetching user information
toast.error("Failed to load user information")
}
}
@ -56,20 +59,52 @@ export default function UserCreate() {
...prev,
[name]: value,
}))
// Clear error for this field when user types
if (validationErrors[name]) {
setValidationErrors(prev => {
const updated = { ...prev }
delete updated[name]
return updated
})
}
}
const validateForm = () => {
try {
UserUpdateSchema.parse(formData)
setValidationErrors({})
return true
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {}
error.errors.forEach((err) => {
const path = err.path.join('.')
errors[path] = err.message
})
setValidationErrors(errors)
return false
}
return false
}
}
const getFieldError = (fieldName: string) => {
return validationErrors[fieldName] || null
}
// In your React component:
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.email || !formData.current_password) {
toast.error("Please fill in all required fields");
return;
// Zod validation
if (!validateForm()) {
// Show the first validation error as a toast
const firstError = Object.values(validationErrors)[0]
if (firstError) {
toast.error(firstError)
}
if (formData.password && formData.password !== formData.password_confirmation) {
toast.error("Passwords don't match");
return;
return
}
try {
@ -85,9 +120,19 @@ export default function UserCreate() {
});
toast.success(response.message || "User updated successfully!");
navigate("/admin/settings");
// Log out the user after successful profile update
toast.success("You will now be logged out for security reasons. Please log in with your updated credentials.", {
duration: 5000
});
// Small delay to allow the toast message to be seen
setTimeout(async () => {
await apiService.logout();
navigate("/login");
}, 2000);
} catch (error: any) {
console.error("Error updating user:", error);
// Handle error when updating user
if (error.response?.data?.message) {
toast.error(error.response.data.message);
@ -168,8 +213,11 @@ export default function UserCreate() {
value={formData.name}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.name ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('name') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('name')}</p>
)}
</div>
<div className="space-y-2">
@ -184,8 +232,11 @@ export default function UserCreate() {
value={formData.email}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.email ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('email') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('email')}</p>
)}
</div>
<div className="border-t border-gray-800 pt-6">
@ -203,46 +254,65 @@ export default function UserCreate() {
value={formData.current_password}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.current_password ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('current_password') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('current_password')}</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="password" className="text-gray-300">
Password <span className="text-red-400">*</span>
New Password
</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter password"
placeholder="Enter new password (optional)"
value={formData.password}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('password') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('password')}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password_confirmation" className="text-gray-300">
Confirm Password <span className="text-red-400">*</span>
Confirm New Password
</Label>
<Input
id="password_confirmation"
name="password_confirmation"
type="password"
placeholder="Confirm password"
placeholder="Confirm new password"
value={formData.password_confirmation}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password_confirmation ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('password_confirmation') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('password_confirmation')}</p>
)}
</div>
</div>
</div>
</div>
{/* Display validation errors summary if needed */}
{Object.keys(validationErrors).length > 0 && (
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg">
<h4 className="text-red-400 font-medium mb-2">Please fix the following errors:</h4>
<ul className="text-sm text-red-300 space-y-1">
{Object.entries(validationErrors).map(([field, error]) => (
<li key={field}> {error}</li>
))}
</ul>
</div>
)}
</div>
</CardContent>
@ -262,7 +332,7 @@ export default function UserCreate() {
className="bg-[#9B2335] hover:bg-[#9B2335]/90 text-white shadow-lg"
>
<UserPlus className="mr-2 size-4" />
{isSubmitting ? "Creating..." : "Create User"}
{isSubmitting ? "Updating..." : "Update User"}
</Button>
</CardFooter>
</form>

View File

@ -1,513 +1,209 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { useState } from "react"
import { useNavigate } from "react-router-dom"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Progress } from "@/components/ui/progress"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import type { Person, Migration, Naturalization, Residence, Family, Internment, ExistingPhoto } from "@/types/api"
import {
ChevronLeft,
ChevronRight,
Save,
User,
MapPin,
FileText,
Home,
Users,
Shield,
Camera,
} from "lucide-react"
import apiService from "@/services/apiService"
import { showSuccessToast, showErrorToast, showUpdateItemToast } from "@/utils/toast"
import { buildFormData } from "@/utils/formDataBuilder"
// Import confirmation modals
import AddDialog from "@/components/admin/migrant/Modal/AddDialog"
import UpdateDialog from "@/components/admin/migrant/Modal/UpdateDialog"
// Import hooks
import { useMigrantForm } from "@/hooks/useMigrantForm"
import { usePhotoManagement } from "@/hooks/usePhotoManagement"
import { useMigrantData } from "@/hooks/useMigrantData"
// Import components
import AddDialog from "./Modal/AddDialog"
import UpdateDialog from "./Modal/UpdateDialog"
// Import step components
import PersonDetailsStep from "@/components/admin/migrant/form-steps/PersonDetailsStep"
import MigrationInfoStep from "@/components/admin/migrant/form-steps/MigrationInfoStep"
import NaturalizationStep from "@/components/admin/migrant/form-steps/NaturalizationStep"
import ResidenceStep from "@/components/admin/migrant/form-steps/ResidenceStep"
import FamilyStep from "@/components/admin/migrant/form-steps/FamilyStep"
import InternmentStep from "@/components/admin/migrant/form-steps/InternmentStep"
import PhotosStep from "@/components/admin/migrant/form-steps/PhotosStep"
import PersonDetailsStep from "./form-steps/PersonDetailsStep"
import MigrationInfoStep from "./form-steps/MigrationInfoStep"
import NaturalizationStep from "./form-steps/NaturalizationStep"
import ResidenceStep from "./form-steps/ResidenceStep"
import FamilyStep from "./form-steps/FamilyStep"
import InternmentStep from "./form-steps/InternmentStep"
import PhotosStep from "./form-steps/PhotosStep"
import ReviewStep from "./form-steps/ReviewStep"
import StepperHeader from "./form-steps/StepperHeader"
import StepperNavigation from "./form-steps/StepperNavigation"
const API_BASE_URL = "http://localhost:8000"
const steps = ["Person Details", "Migration Info", "Naturalization", "Residence", "Family", "Internment", "Photos"]
const stepDescriptions = [
"Basic personal details and identification information",
"Immigration and arrival information in Australia",
"Citizenship and naturalization records",
"Residential history and location details",
"Family members and relationship information",
"Internment and detention records",
"Upload photos and supporting documents",
]
const stepIcons = [User, MapPin, FileText, Home, Users, Shield, Camera]
const StepperForm = () => {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEditMode = Boolean(id)
// Form state
// Form state management
const {
formData,
setters,
resetForm,
populateFormData,
validation
} = useMigrantForm()
const photoManagement = usePhotoManagement()
const { loading, isEditMode, initialDataLoaded } = useMigrantData(
populateFormData,
photoManagement.populatePhotoData
)
// Local state
const [currentStep, setCurrentStep] = useState(0)
const [loading, setLoading] = useState(false)
const [initialDataLoaded, setInitialDataLoaded] = useState(false)
// Modal states
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
// Initial state functions
const getInitialPersonState = (): Person => ({
surname: "",
christian_name: "",
date_of_birth: "",
place_of_birth: "",
date_of_death: "",
occupation: "",
additional_notes: "",
reference: "",
id_card_no: "",
person_id: 0,
})
const renderFormField = (
label: string,
value: string,
onChange: (value: string) => void,
type = "text",
placeholder?: string,
required?: boolean,
fieldPath?: string // Add this to identify the field for validation
) => {
const hasError = fieldPath ? validation.hasFieldError(fieldPath) : false
const errorMessage = fieldPath ? validation.getFieldError(fieldPath) : null
const getInitialMigrationState = (): Migration => ({
date_of_arrival_aus: "",
date_of_arrival_nt: "",
arrival_period: "",
data_source: "",
})
const getInitialNaturalizationState = (): Naturalization => ({
date_of_naturalisation: "",
no_of_cert: "",
issued_at: "",
})
const getInitialResidenceState = (): Residence => ({
town_or_city: "",
home_at_death: "",
})
const getInitialFamilyState = (): Family => ({
names_of_parents: "",
names_of_children: "",
})
const getInitialInternmentState = (): Internment => ({
corps_issued: "",
interned_in: "",
sent_to: "",
internee_occupation: "",
internee_address: "",
cav: "",
})
// Form data state
const [person, setPerson] = useState<Person>(getInitialPersonState())
const [migration, setMigration] = useState<Migration>(getInitialMigrationState())
const [naturalization, setNaturalization] = useState<Naturalization>(getInitialNaturalizationState())
const [residence, setResidence] = useState<Residence>(getInitialResidenceState())
const [family, setFamily] = useState<Family>(getInitialFamilyState())
const [internment, setInternment] = useState<Internment>(getInitialInternmentState())
// Photo state
const [photos, setPhotos] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [captions, setCaptions] = useState<string[]>([])
const [mainPhotoIndex, setMainPhotoIndex] = useState<number | null>(null)
const [existingPhotos, setExistingPhotos] = useState<ExistingPhoto[]>([])
// Load existing data when in edit mode
useEffect(() => {
if (isEditMode && id && !initialDataLoaded) {
loadExistingData()
}
}, [id, isEditMode, initialDataLoaded])
// Helper function to format date from API format to yyyy-MM-dd
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return ""
try {
const date = new Date(dateString)
return date.toISOString().split("T")[0]
} catch (error) {
console.error("Error formatting date:", error)
return ""
}
return (
<div className="space-y-2">
<Label htmlFor={label.toLowerCase().replace(/\s+/g, "-")} className="text-sm font-medium text-gray-300">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</Label>
{type === "textarea" ? (
<Textarea
id={label.toLowerCase().replace(/\s+/g, "-")}
placeholder={placeholder || label}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`min-h-[100px] resize-none bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335] ${
hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''
}`}
/>
) : (
<Input
id={label.toLowerCase().replace(/\s+/g, "-")}
type={type}
placeholder={placeholder || label}
value={value}
onChange={(e) => onChange(e.target.value)}
className={`bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335] ${
hasError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''
}`}
/>
)}
{errorMessage && (
<p className="text-sm text-red-400 mt-1">{errorMessage}</p>
)}
</div>
)
}
const loadExistingData = async () => {
try {
setLoading(true)
const personId = Number.parseInt(id!, 10)
if (isNaN(personId)) {
throw new Error("Invalid person ID")
}
const migrantData = await apiService.getMigrantById(personId)
// FIX: Populate person data from migrantData.person, not current person state
setPerson({
surname: migrantData.surname || "",
christian_name: migrantData.christian_name || "",
date_of_birth: formatDate(migrantData.date_of_birth),
place_of_birth: migrantData.place_of_birth || "",
date_of_death: formatDate(migrantData.date_of_death),
occupation: migrantData.occupation || "",
additional_notes: migrantData.additional_notes || "",
reference: migrantData.reference || "",
id_card_no: migrantData.id_card_no || "",
person_id: migrantData.person_id || 0,
})
// Populate migration data
if (migrantData.migration) {
setMigration({
date_of_arrival_aus: formatDate(migrantData.migration.date_of_arrival_aus),
date_of_arrival_nt: formatDate(migrantData.migration.date_of_arrival_nt),
arrival_period: migrantData.migration.arrival_period || "",
data_source: migrantData.migration.data_source || "",
})
}
// Populate naturalization data
if (migrantData.naturalization) {
setNaturalization({
date_of_naturalisation: formatDate(migrantData.naturalization.date_of_naturalisation),
no_of_cert: migrantData.naturalization.no_of_cert || "",
issued_at: migrantData.naturalization.issued_at || "",
})
}
// Populate residence data
if (migrantData.residence) {
setResidence({
town_or_city: migrantData.residence.town_or_city || "",
home_at_death: migrantData.residence.home_at_death || "",
})
}
// Populate family data
if (migrantData.family) {
setFamily({
names_of_parents: migrantData.family.names_of_parents || "",
names_of_children: migrantData.family.names_of_children || "",
})
}
// Populate internment data
if (migrantData.internment) {
setInternment({
corps_issued: migrantData.internment.corps_issued || "",
interned_in: migrantData.internment.interned_in || "",
sent_to: migrantData.internment.sent_to || "",
internee_occupation: migrantData.internment.internee_occupation || "",
internee_address: migrantData.internment.internee_address || "",
cav: migrantData.internment.cav || "",
})
}
if (migrantData.photos && Array.isArray(migrantData.photos) && migrantData.photos.length > 0) {
const photoData = migrantData.photos as ExistingPhoto[]
setExistingPhotos(photoData)
// Set up captions array
const existingCaptions = photoData.map((photo: ExistingPhoto) => photo.caption || "")
setCaptions(existingCaptions)
// Find and set the main photo index
const mainPhotoIdx = photoData.findIndex((photo: ExistingPhoto) => photo.is_profile_photo)
if (mainPhotoIdx !== -1) {
setMainPhotoIndex(mainPhotoIdx)
}
}
setInitialDataLoaded(true)
} catch (error) {
console.error("Error loading migrant data:", error)
showErrorToast("Failed to load migrant data for editing.")
} finally {
setLoading(false)
}
}
const resetForm = () => {
setPerson(getInitialPersonState())
setMigration(getInitialMigrationState())
setNaturalization(getInitialNaturalizationState())
setResidence(getInitialResidenceState())
setFamily(getInitialFamilyState())
setInternment(getInitialInternmentState())
setPhotos([])
setPhotoPreviews([])
setCaptions([])
setMainPhotoIndex(null)
setExistingPhotos([])
setInitialDataLoaded(false)
}
const handlePhotoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFiles = Array.from(e.target.files)
setPhotos((prev) => [...prev, ...selectedFiles])
const newPreviews = selectedFiles.map((file) => URL.createObjectURL(file))
setPhotoPreviews((prev) => [...prev, ...newPreviews])
setCaptions((prev) => {
const newCaptions = [...prev]
for (let i = 0; i < selectedFiles.length; i++) {
newCaptions.push("")
}
return newCaptions
})
if (mainPhotoIndex === null && (existingPhotos.length > 0 || selectedFiles.length > 0)) {
setMainPhotoIndex(0)
}
}
// Reset the file input
e.target.value = ""
}
const removeExistingPhoto = (index: number) => {
const wasMainPhoto = mainPhotoIndex === index
setExistingPhotos((prev) => prev.filter((_, i) => i !== index))
// Remove corresponding caption
setCaptions((prev) => {
const newCaptions = [...prev]
newCaptions.splice(index, 1)
return newCaptions
})
// Handle main photo index adjustment
if (wasMainPhoto) {
// If we're removing the main photo, try to set another photo as main
const totalRemainingPhotos = existingPhotos.length - 1 + photos.length
if (totalRemainingPhotos > 0) {
// Set the first remaining photo as main
setMainPhotoIndex(0)
} else {
setMainPhotoIndex(null)
}
} else if (mainPhotoIndex !== null && mainPhotoIndex > index) {
// Adjust main photo index if it's after the removed photo
setMainPhotoIndex(mainPhotoIndex - 1)
}
}
const removeNewPhoto = (photoIndex: number) => {
const totalIndex = existingPhotos.length + photoIndex
const wasMainPhoto = mainPhotoIndex === totalIndex
// Remove the photo and its preview
setPhotos((prev) => prev.filter((_, i) => i !== photoIndex))
setPhotoPreviews((prev) => prev.filter((_, i) => i !== photoIndex))
// Remove corresponding caption from the total captions array
setCaptions((prev) => {
const newCaptions = [...prev]
newCaptions.splice(totalIndex, 1)
return newCaptions
})
// Handle main photo index adjustment
if (wasMainPhoto) {
// If we're removing the main photo, try to set another photo as main
const totalRemainingPhotos = existingPhotos.length + photos.length - 1
if (totalRemainingPhotos > 0) {
// Set the first remaining photo as main
setMainPhotoIndex(0)
} else {
setMainPhotoIndex(null)
}
} else if (mainPhotoIndex !== null && mainPhotoIndex > totalIndex) {
// Adjust main photo index if it's after the removed photo
setMainPhotoIndex(mainPhotoIndex - 1)
const renderStepContent = () => {
switch (currentStep) {
case 0:
return <PersonDetailsStep person={formData.person} setPerson={setters.setPerson} renderFormField={renderFormField} />
case 1:
return <MigrationInfoStep migration={formData.migration} setMigration={setters.setMigration} renderFormField={renderFormField} />
case 2:
return <NaturalizationStep naturalization={formData.naturalization} setNaturalization={setters.setNaturalization} renderFormField={renderFormField} />
case 3:
return <ResidenceStep residence={formData.residence} setResidence={setters.setResidence} renderFormField={renderFormField} />
case 4:
return <FamilyStep family={formData.family} setFamily={setters.setFamily} renderFormField={renderFormField} />
case 5:
return <InternmentStep internment={formData.internment} setInternment={setters.setInternment} renderFormField={renderFormField} />
case 6:
return (
<PhotosStep
photos={photoManagement.photos}
photoPreviews={photoManagement.photoPreviews}
captions={photoManagement.captions}
existingPhotos={photoManagement.existingPhotos}
mainPhotoIndex={photoManagement.mainPhotoIndex}
API_BASE_URL={apiService.baseURL}
handlePhotoChange={photoManagement.handlePhotoChange}
removeExistingPhoto={photoManagement.removeExistingPhoto}
removeNewPhoto={photoManagement.removeNewPhoto}
setCaptions={photoManagement.setCaptions}
setMainPhotoIndex={photoManagement.setMainPhotoIndex}
/>
)
case 7:
return (
<ReviewStep
person={formData.person}
migration={formData.migration}
naturalization={formData.naturalization}
residence={formData.residence}
family={formData.family}
internment={formData.internment}
photos={photoManagement.photos}
photoPreviews={photoManagement.photoPreviews}
captions={photoManagement.captions}
existingPhotos={photoManagement.existingPhotos}
mainPhotoIndex={photoManagement.mainPhotoIndex ?? -1}
API_BASE_URL={apiService.baseURL}
/>
)
default:
return null
}
}
const submitForm = async () => {
try {
const formData = new FormData()
// Add person data
console.log('Person data:', person);
Object.entries(person).forEach(([key, value]) => {
if (value) formData.append(key, value)
const formDataToSubmit = buildFormData({
...formData,
photos: photoManagement.photos,
captions: photoManagement.captions,
mainPhotoIndex: photoManagement.mainPhotoIndex,
existingPhotos: photoManagement.existingPhotos,
removedPhotoIds: photoManagement.removedPhotoIds,
isEditMode,
})
// Add migration data
console.log('Migration data:', migration);
if (Object.values(migration).some(v => v)) {
Object.entries(migration).forEach(([key, value]) => {
if (value) formData.append(`migration[${key}]`, value)
})
}
// Add naturalization data
if (Object.values(naturalization).some(v => v)) {
Object.entries(naturalization).forEach(([key, value]) => {
if (value) formData.append(`naturalization[${key}]`, value)
})
}
// Add residence data
console.log('Residence data:', residence);
if (Object.values(residence).some(v => v)) {
Object.entries(residence).forEach(([key, value]) => {
if (value) formData.append(`residence[${key}]`, value)
})
}
// Add family data
console.log('Family data:', family);
if (Object.values(family).some(v => v)) {
Object.entries(family).forEach(([key, value]) => {
if (value) formData.append(`family[${key}]`, value)
})
}
// Add internment data
console.log('Internment data:', internment);
if (Object.values(internment).some(v => v)) {
Object.entries(internment).forEach(([key, value]) => {
if (value) formData.append(`internment[${key}]`, value)
})
}
// Add photos
console.log('Photos:', photos);
photos.forEach((photo) => {
formData.append('photos[]', photo)
})
// FIXED: Add captions for new photos only (existing photo captions handled separately)
console.log('New photo captions:');
const newPhotoCaptions = captions.slice(existingPhotos.length); // Get captions for new photos only
newPhotoCaptions.forEach((caption, index) => {
console.log(`New photo ${index} caption:`, caption);
formData.append(`captions[${index}]`, caption || '') // Always send caption, even if empty
})
// IMPROVED MAIN PHOTO LOGIC
console.log('=== MAIN PHOTO DEBUG ===');
console.log('Main photo index:', mainPhotoIndex);
console.log('Existing photos count:', existingPhotos.length);
console.log('New photos count:', photos.length);
console.log('Is edit mode:', isEditMode);
// Always send profile photo data if mainPhotoIndex is set
if (mainPhotoIndex !== null) {
formData.append('set_as_profile', 'true')
if (mainPhotoIndex < existingPhotos.length) {
// Main photo is an existing photo
const existingPhoto = existingPhotos[mainPhotoIndex]
console.log('Setting existing photo as main:', existingPhoto.id);
formData.append('profile_photo_id', existingPhoto.id.toString())
if (isEditMode) {
const id = window.location.pathname.split('/').pop()
return await apiService.updateMigrant(parseInt(id!), formDataToSubmit)
} else {
// Main photo is a new photo
const newPhotoIndex = mainPhotoIndex - existingPhotos.length
console.log('Setting new photo as main, index:', newPhotoIndex);
// Validate the index
if (newPhotoIndex >= 0 && newPhotoIndex < photos.length) {
formData.append('profile_photo_index', newPhotoIndex.toString())
console.log('✅ Valid new photo index sent:', newPhotoIndex);
// CRITICAL FIX: Ensure the photo at this index is marked as profile
formData.append('new_profile_photo_index', newPhotoIndex.toString())
} else {
console.error('❌ Invalid new photo index:', newPhotoIndex, 'Photos length:', photos.length);
return await apiService.createMigrant(formDataToSubmit)
}
}
} else {
console.log('⚠️ No main photo selected');
formData.append('set_as_profile', 'false')
}
// Handle existing photo updates (captions and profile status)
if (existingPhotos.length > 0) {
console.log('Processing existing photos for caption updates...');
existingPhotos.forEach((photo, index) => {
formData.append(`existing_photos[${index}][id]`, photo.id.toString())
// Send caption (from the first part of captions array)
const caption = captions[index] || ''
formData.append(`existing_photos[${index}][caption]`, caption)
console.log(`Caption for existing photo ${photo.id}:`, caption);
// FIXED: Explicitly set profile status for existing photos
const isThisPhotoMain = mainPhotoIndex === index
formData.append(`existing_photos[${index}][is_profile_photo]`, isThisPhotoMain ? 'true' : 'false')
console.log(`Existing photo ${photo.id} is_profile_photo:`, isThisPhotoMain);
})
}
// ADDITIONAL FIX: Send explicit photo metadata for new photos
photos.forEach((_photo, index) => {
const totalIndex = existingPhotos.length + index
const isThisPhotoMain = mainPhotoIndex === totalIndex
formData.append(`new_photos[${index}][is_profile_photo]`, isThisPhotoMain ? 'true' : 'false')
formData.append(`new_photos[${index}][caption]`, captions[totalIndex] || '')
console.log(`New photo ${index} metadata:`, {
is_profile_photo: isThisPhotoMain,
caption: captions[totalIndex] || '',
total_index: totalIndex
});
})
// Log FormData contents for debugging
console.log('=== FORM DATA CONTENTS ===');
for (let [key, value] of formData.entries()) {
if (value instanceof File) {
console.log(key, ':', `[File: ${value.name}]`);
} else {
console.log(key, ':', value);
}
}
let response
if (isEditMode && id) {
console.log('Updating migrant:', id);
response = await apiService.updateMigrant(parseInt(id), formData)
} else {
console.log('Creating new migrant');
response = await apiService.createMigrant(formData)
}
return response
} catch (error) {
console.error('Form submission error:', error)
throw error
}
}
const handleNext = () => {
// Validate current section before moving to next step
const sectionNames = ['person', 'migration', 'naturalization', 'residence', 'family', 'internment']
if (currentStep < sectionNames.length) {
const currentSection = sectionNames[currentStep] as 'person' | 'migration' | 'naturalization' | 'residence' | 'family' | 'internment'
const isValid = validation.validateSection(currentSection)
if (isValid || currentStep > 0) { // Allow moving forward from non-required sections
setCurrentStep(prev => prev + 1)
}
} else {
setCurrentStep(prev => prev + 1)
}
}
const handleSubmit = () => {
if (!isSubmitting) {
// Validate entire form before submitting
const isValid = validation.validateForm()
if (!isValid) {
showErrorToast("Please fix the errors in the form before submitting.")
return
}
if (isEditMode) {
setIsUpdateDialogOpen(true)
} else {
@ -520,10 +216,10 @@ const StepperForm = () => {
if (!isSubmitting) {
try {
setIsSubmitting(true)
const response = await submitForm()
await submitForm()
if (isEditMode) {
showUpdateItemToast(`Migrant ${person.surname}, ${person.christian_name}`, () => {
showUpdateItemToast(`Migrant ${formData.person.surname}, ${formData.person.christian_name}`, () => {
navigate(`/admin/migrants`)
})
} else {
@ -531,11 +227,9 @@ const StepperForm = () => {
navigate(`/admin/migrants`)
})
resetForm()
photoManagement.resetPhotos()
}
return response
} catch (error) {
console.error("Form submission error:", error)
showErrorToast("There was a problem saving the migrant data. Please try again.")
} finally {
setIsSubmitting(false)
@ -545,90 +239,6 @@ const StepperForm = () => {
}
}
const renderFormField = (
label: string,
value: string,
onChange: (value: string) => void,
type = "text",
placeholder?: string,
required?: boolean,
) => (
<div className="space-y-2">
<Label htmlFor={label.toLowerCase().replace(/\s+/g, "-")} className="text-sm font-medium text-gray-300">
{label}
{required && <span className="text-red-400 ml-1">*</span>}
</Label>
{type === "textarea" ? (
<Textarea
id={label.toLowerCase().replace(/\s+/g, "-")}
placeholder={placeholder || label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="min-h-[100px] resize-none bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335]"
/>
) : (
<Input
id={label.toLowerCase().replace(/\s+/g, "-")}
type={type}
placeholder={placeholder || label}
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-gray-800 border-gray-700 text-white placeholder:text-gray-500 focus:border-[#9B2335] focus:ring-[#9B2335]"
/>
)}
</div>
)
const renderStepContent = () => {
switch (currentStep) {
case 0:
return <PersonDetailsStep person={person} setPerson={setPerson} renderFormField={renderFormField} />
case 1:
return <MigrationInfoStep migration={migration} setMigration={setMigration} renderFormField={renderFormField} />
case 2:
return (
<NaturalizationStep
naturalization={naturalization}
setNaturalization={setNaturalization}
renderFormField={renderFormField}
/>
)
case 3:
return <ResidenceStep residence={residence} setResidence={setResidence} renderFormField={renderFormField} />
case 4:
return <FamilyStep family={family} setFamily={setFamily} renderFormField={renderFormField} />
case 5:
return (
<InternmentStep internment={internment} setInternment={setInternment} renderFormField={renderFormField} />
)
case 6:
return (
<PhotosStep
photos={photos}
photoPreviews={photoPreviews}
captions={captions}
existingPhotos={existingPhotos}
mainPhotoIndex={mainPhotoIndex}
API_BASE_URL={API_BASE_URL}
handlePhotoChange={handlePhotoChange}
removeExistingPhoto={removeExistingPhoto}
removeNewPhoto={removeNewPhoto}
setCaptions={setCaptions}
setMainPhotoIndex={setMainPhotoIndex}
/>
)
default:
return null
}
}
if (loading && isEditMode && !initialDataLoaded) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
@ -645,115 +255,35 @@ const StepperForm = () => {
return (
<div className="max-w-6xl mx-auto mt-8 mb-24 p-4">
<Card className="shadow-2xl border border-gray-800 bg-gray-900/50 overflow-hidden backdrop-blur-sm">
<CardHeader className="bg-gray-800 p-8 border-b border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<CardTitle className="text-xl md:text-2xl font-bold text-white">
{isEditMode ? `Edit ${steps[currentStep]}` : steps[currentStep]}
</CardTitle>
<CardDescription className="text-gray-400 mt-1">{stepDescriptions[currentStep]}</CardDescription>
</div>
<Badge variant="outline" className="text-sm border-gray-700 text-gray-300">
Step {currentStep + 1} of {steps.length}
</Badge>
</div>
<StepperHeader currentStep={currentStep} isEditMode={isEditMode} />
<div className="mt-6">
<div className="flex justify-between text-sm text-gray-400 mb-2">
<span>Progress</span>
<span>{Math.round((currentStep / (steps.length - 1)) * 100)}% Complete</span>
<CardContent className="p-8 bg-gray-900">
{renderStepContent()}
{/* Display validation errors summary if any */}
{Object.keys(validation.validationErrors).length > 0 && (
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg">
<h4 className="text-red-400 font-medium mb-2">Please fix the following errors:</h4>
<ul className="text-sm text-red-300 space-y-1">
{Object.entries(validation.validationErrors).map(([field, error]) => (
<li key={field}> {error}</li>
))}
</ul>
</div>
<Progress
value={(currentStep / (steps.length - 1)) * 100}
className="h-2 bg-gray-800"
style={
{
"--progress-foreground": "#9B2335",
} as React.CSSProperties
}
)}
</CardContent>
<StepperNavigation
currentStep={currentStep}
totalSteps={8}
isSubmitting={isSubmitting}
isEditMode={isEditMode}
onPrevious={() => setCurrentStep(prev => prev - 1)}
onNext={handleNext}
onSubmit={handleSubmit}
/>
</div>
{/* Step indicators */}
<div className="flex justify-between mt-4 overflow-x-auto">
{steps.map((step, index) => {
const StepIcon = stepIcons[index]
return (
<div key={index} className="flex flex-col items-center min-w-0 flex-1">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-200 ${
index <= currentStep
? "bg-[#9B2335] text-white shadow-lg"
: "bg-gray-800 text-gray-500 border border-gray-700"
}`}
>
<StepIcon className="w-5 h-5" />
</div>
<span
className={`text-xs text-center px-1 transition-colors duration-200 ${
index <= currentStep ? "text-[#9B2335] font-medium" : "text-gray-500"
}`}
>
{step}
</span>
</div>
)
})}
</div>
</CardHeader>
<CardContent className="p-8 bg-gray-900">{renderStepContent()}</CardContent>
<div className="bg-gray-800 px-8 py-6 border-t border-gray-700">
<div className="flex justify-between items-center">
<Button
variant="outline"
disabled={currentStep === 0}
onClick={() => setCurrentStep((prev) => prev - 1)}
className="flex items-center gap-2 border-gray-700 text-gray-300 hover:bg-gray-700 hover:text-white disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span>
Step {currentStep + 1} of {steps.length}
</span>
</div>
{currentStep < steps.length - 1 ? (
<Button
onClick={() => setCurrentStep((prev) => prev + 1)}
className="flex items-center gap-2 bg-[#9B2335] hover:bg-[#9B2335]/90 shadow-lg"
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
) : (
<Button
onClick={handleSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 shadow-lg"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
{isEditMode ? "Update Record" : "Submit Form"}
</>
)}
</Button>
)}
</div>
</div>
</Card>
{/* Confirmation Dialogs */}
<AddDialog
open={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}

View File

@ -1,24 +1,22 @@
import React from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Upload, X, ImageIcon } from "lucide-react"
import React from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Card, CardContent } from '@/components/ui/card'
import { Camera, Star, Trash2 } from 'lucide-react'
interface PhotosStepProps {
photos: File[]
photoPreviews: string[]
captions: string[]
existingPhotos: any[]
existingPhotos: any
mainPhotoIndex: number | null
API_BASE_URL: string
handlePhotoChange: (e: React.ChangeEvent<HTMLInputElement>) => void
removeExistingPhoto: (index: number) => void
removeNewPhoto: (index: number) => void
setCaptions: React.Dispatch<React.SetStateAction<string[]>>
setMainPhotoIndex: React.Dispatch<React.SetStateAction<number | null>>
setCaptions: (captions: string[]) => void
setMainPhotoIndex: (index: number) => void
}
const PhotosStep: React.FC<PhotosStepProps> = ({
@ -34,18 +32,70 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
setCaptions,
setMainPhotoIndex,
}) => {
const [isDragOver, setIsDragOver] = React.useState(false)
const updateCaption = (index: number, newCaption: string) => {
const newCaptions = [...captions]
newCaptions[index] = newCaption
setCaptions(newCaptions)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const files = Array.from(e.dataTransfer.files).filter(file =>
file.type.startsWith('image/')
)
if (files.length > 0) {
const syntheticEvent = {
target: {
files: files
}
} as unknown as React.ChangeEvent<HTMLInputElement>
handlePhotoChange(syntheticEvent)
}
}
return (
<div className="space-y-6">
{/* File Upload Section */}
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<div className="space-y-2">
<Label htmlFor="photo-upload" className="text-lg font-medium text-gray-700 cursor-pointer">
Upload Photos & Documents
<div>
<Label htmlFor="photo-upload" className="text-sm font-medium text-gray-300 mb-4 block">
Upload Photos
</Label>
<p className="text-sm text-gray-500">
Select multiple files to upload. Supported formats: JPG, PNG, PDF
</p>
<div
className={`border-2 border-dashed rounded-lg p-6 transition-all duration-200 ${
isDragOver
? 'border-blue-400 bg-blue-900/20 scale-[1.02]'
: 'border-gray-600 bg-gray-800/50 hover:border-gray-500 hover:bg-gray-800/70'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-4">
<Button
type="button"
variant="outline"
className="border-gray-900 text-white bg-gray-700 hover:bg-gray-600"
onClick={() => document.getElementById('photo-upload')?.click()}
>
<Camera className="w-4 h-4 mr-2" />
Add Photos
</Button>
<Input
id="photo-upload"
type="file"
@ -54,75 +104,59 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
onChange={handlePhotoChange}
className="hidden"
/>
<Button
variant="outline"
className="mt-4"
onClick={() => document.getElementById("photo-upload")?.click()}
>
<Upload className="w-4 h-4 mr-2" />
Choose Files
</Button>
</div>
<p className="text-sm text-gray-400 text-center">
{isDragOver
? "Drop your photos here!"
: "Click to select photos or drag and drop them here"
}
</p>
</div>
</div>
</div>
{existingPhotos.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-gray-600" />
<h3 className="text-lg font-medium text-white">Existing Photos</h3>
<Badge variant="secondary">{existingPhotos.length}</Badge>
</div>
<h3 className="text-lg font-semibold text-white border-b border-gray-600 pb-2">
Existing Photos ({existingPhotos.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{existingPhotos.map((photo, index) => (
<Card key={`existing-${photo.id}`} className="overflow-hidden">
{existingPhotos.map((photo: any, index: number) => (
<Card key={`existing-${photo.id}`} className="bg-gray-800 border-gray-700">
<CardContent className="p-4">
<div className="relative">
<img
src={`${API_BASE_URL}${photo.file_path}`}
alt={`Existing ${index}`}
className="w-full h-48 object-cover"
alt={photo.caption || `Photo ${index + 1}`}
className="w-full h-48 object-cover rounded-md"
/>
{mainPhotoIndex === index && (
<Badge className="absolute top-2 left-2 bg-green-600">Main Photo</Badge>
)}
<div className="absolute top-2 right-2 flex gap-2">
<Button
variant="destructive"
type="button"
size="sm"
className="absolute top-2 right-2"
variant={mainPhotoIndex === index ? "default" : "outline"}
className={mainPhotoIndex === index ? "bg-yellow-600 hover:bg-yellow-700" : ""}
onClick={() => setMainPhotoIndex(index)}
>
<Star className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => removeExistingPhoto(index)}
>
<X className="w-4 h-4" />
<Trash2 className="w-4 h-4" />
</Button>
</div>
<CardContent className="p-4 space-y-3">
<div className="space-y-2">
<Label htmlFor={`caption-existing-${index}`} className="text-sm font-medium">
Caption
</Label>
</div>
<Input
id={`caption-existing-${index}`}
placeholder="Enter photo caption"
value={typeof captions[index] === "string" ? captions[index] : ""}
onChange={(e) =>
setCaptions((prev) => {
const copy = [...prev]
copy[index] = e.target.value
return copy
})
}
className="text-sm"
type="text"
placeholder="Add caption..."
value={captions[index] || ''}
onChange={(e) => updateCaption(index, e.target.value)}
className="mt-2 bg-gray-700 border-gray-600 text-white"
/>
</div>
<RadioGroup
value={mainPhotoIndex === index ? "main" : ""}
onValueChange={() => setMainPhotoIndex(index)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="main" id={`main-existing-${index}`} />
<Label htmlFor={`main-existing-${index}`} className="text-sm">
Set as main photo
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
))}
@ -130,66 +164,51 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
</div>
)}
{photos.length > 0 && (
{/* New Photos Section */}
{photoPreviews.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Upload className="w-5 h-5 text-gray-600" />
<h3 className="text-lg font-medium text-white">New Photos</h3>
<Badge variant="secondary">{photos.length}</Badge>
</div>
<h3 className="text-lg font-semibold text-white border-b border-gray-600 pb-2">
New Photos ({photoPreviews.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{photos.map((_, index) => {
const actualIndex = existingPhotos.length + index
{photoPreviews.map((preview, photoIndex) => {
const totalIndex = existingPhotos.length + photoIndex
return (
<Card key={`new-${index}`} className="overflow-hidden">
<Card key={`new-${photoIndex}`} className="bg-gray-800 border-gray-700">
<CardContent className="p-4">
<div className="relative">
<img
src={photoPreviews[index] || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
alt={`Preview ${index}`}
className="w-full h-48 object-cover"
src={preview}
alt={`New photo ${photoIndex + 1}`}
className="w-full h-48 object-cover rounded-md"
/>
{mainPhotoIndex === actualIndex && (
<Badge className="absolute top-2 left-2 bg-green-600">Main Photo</Badge>
)}
<div className="absolute top-2 right-2 flex gap-2">
<Button
variant="destructive"
type="button"
size="sm"
className="absolute top-2 right-2"
onClick={() => removeNewPhoto(actualIndex)}
variant={mainPhotoIndex === totalIndex ? "default" : "outline"}
className={mainPhotoIndex === totalIndex ? "bg-yellow-600 hover:bg-yellow-700" : ""}
onClick={() => setMainPhotoIndex(totalIndex)}
>
<X className="w-4 h-4" />
<Star className="w-4 h-4" />
</Button>
<Button
type="button"
size="sm"
variant="destructive"
onClick={() => removeNewPhoto(photoIndex)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
<CardContent className="p-4 space-y-3">
<div className="space-y-2">
<Label htmlFor={`caption-new-${index}`} className="text-sm font-medium">
Caption
</Label>
</div>
<Input
id={`caption-new-${index}`}
placeholder="Enter photo caption"
value={captions[actualIndex] ?? ""}
onChange={(e) =>
setCaptions((prev) => {
const copy = [...prev]
copy[actualIndex] = e.target.value
return copy
})
}
className="text-sm"
type="text"
placeholder="Add caption..."
value={captions[totalIndex] || ''}
onChange={(e) => updateCaption(totalIndex, e.target.value)}
className="mt-2 bg-gray-700 border-gray-600 text-white"
/>
</div>
<RadioGroup
value={mainPhotoIndex === actualIndex ? "main" : ""}
onValueChange={() => setMainPhotoIndex(actualIndex)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="main" id={`main-new-${index}`} />
<Label htmlFor={`main-new-${index}`} className="text-sm">
Set as main photo
</Label>
</div>
</RadioGroup>
</CardContent>
</Card>
)
@ -197,6 +216,14 @@ const PhotosStep: React.FC<PhotosStepProps> = ({
</div>
</div>
)}
{/* No Photos Message */}
{existingPhotos.length === 0 && photos.length === 0 && (
<div className="text-center py-8 text-gray-400">
<Camera className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No photos uploaded yet. Click "Add Photos" to get started.</p>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,224 @@
import React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import type { Person, Migration, Naturalization, Residence, Family, Internment } from '@/types/api'
interface ReviewStepProps {
person: Person
migration: Migration
naturalization: Naturalization
residence: Residence
family: Family
internment: Internment
photos: File[]
photoPreviews: string[]
captions: string[]
existingPhotos: any[]
mainPhotoIndex: number
API_BASE_URL: string
}
const ReviewStep: React.FC<ReviewStepProps> = ({
person,
migration,
naturalization,
residence,
family,
internment,
photos,
photoPreviews,
captions,
existingPhotos,
mainPhotoIndex,
API_BASE_URL
}) => {
const formatDate = (dateString: string) => {
if (!dateString) return 'Not provided'
try {
return new Date(dateString).toLocaleDateString()
} catch {
return dateString
}
}
const ReviewSection = ({ title, children, isEmpty = false }: {
title: string,
children: React.ReactNode,
isEmpty?: boolean
}) => (
<Card className="bg-gray-800 border-gray-700">
<CardHeader className="pb-3">
<CardTitle className="text-lg text-white flex items-center justify-between">
{title}
{isEmpty && <Badge variant="secondary" className="text-xs">No data</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{children}
</CardContent>
</Card>
)
const DataRow = ({ label, value }: { label: string, value: string }) => (
<div className="flex justify-between items-start py-1">
<span className="text-gray-400 text-sm">{label}:</span>
<span className="text-white text-sm text-right max-w-xs">
{value || 'Not provided'}
</span>
</div>
)
// Check if sections have data
const hasPersonData = person.surname || person.christian_name
const hasMigrationData = migration.date_of_arrival_aus || migration.date_of_arrival_nt || migration.arrival_period || migration.data_source
const hasNaturalizationData = naturalization.date_of_naturalisation || naturalization.no_of_cert || naturalization.issued_at
const hasResidenceData = residence.town_or_city || residence.home_at_death
const hasFamilyData = family.names_of_parents || family.names_of_children
const hasInternmentData = internment.corps_issued || internment.interned_in || internment.sent_to || internment.internee_occupation || internment.internee_address || internment.cav
const hasPhotos = photos.length > 0 || existingPhotos.length > 0
return (
<div className="space-y-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Review Your Information</h2>
<p className="text-gray-400">Please review all the information before submitting</p>
</div>
<div className="grid gap-6">
{/* Personal Details */}
<ReviewSection title="Personal Details" isEmpty={!hasPersonData}>
<DataRow label="Surname" value={person.surname} />
<DataRow label="Christian Name" value={person.christian_name} />
<DataRow label="Date of Birth" value={formatDate(person.date_of_birth)} />
<DataRow label="Place of Birth" value={person.place_of_birth} />
<DataRow label="Date of Death" value={formatDate(person.date_of_death)} />
<DataRow label="Occupation" value={person.occupation} />
<DataRow label="ID Card No" value={person.id_card_no} />
<DataRow label="Reference" value={person.reference} />
{person.additional_notes && (
<div className="pt-2 border-t border-gray-700">
<span className="text-gray-400 text-sm">Additional Notes:</span>
<p className="text-white text-sm mt-1 bg-gray-700/50 p-2 rounded">
{person.additional_notes}
</p>
</div>
)}
</ReviewSection>
{/* Migration Info */}
<ReviewSection title="Migration Information" isEmpty={!hasMigrationData}>
<DataRow label="Date of Arrival (Australia)" value={formatDate(migration.date_of_arrival_aus)} />
<DataRow label="Date of Arrival (NT)" value={formatDate(migration.date_of_arrival_nt)} />
<DataRow label="Arrival Period" value={migration.arrival_period} />
<DataRow label="Data Source" value={migration.data_source} />
</ReviewSection>
{/* Naturalization */}
<ReviewSection title="Naturalization" isEmpty={!hasNaturalizationData}>
<DataRow label="Date of Naturalisation" value={formatDate(naturalization.date_of_naturalisation)} />
<DataRow label="Certificate Number" value={naturalization.no_of_cert} />
<DataRow label="Issued At" value={naturalization.issued_at} />
</ReviewSection>
{/* Residence */}
<ReviewSection title="Residence" isEmpty={!hasResidenceData}>
<DataRow label="Town or City" value={residence.town_or_city} />
<DataRow label="Home at Death" value={residence.home_at_death} />
</ReviewSection>
{/* Family */}
<ReviewSection title="Family" isEmpty={!hasFamilyData}>
<DataRow label="Names of Parents" value={family.names_of_parents} />
<DataRow label="Names of Children" value={family.names_of_children} />
</ReviewSection>
{/* Internment */}
<ReviewSection title="Internment" isEmpty={!hasInternmentData}>
<DataRow label="Corps Issued" value={internment.corps_issued} />
<DataRow label="Interned In" value={internment.interned_in} />
<DataRow label="Sent To" value={internment.sent_to} />
<DataRow label="Internee Occupation" value={internment.internee_occupation} />
<DataRow label="Internee Address" value={internment.internee_address} />
<DataRow label="CAV" value={internment.cav} />
</ReviewSection>
{/* Photos */}
<ReviewSection title="Photos" isEmpty={!hasPhotos}>
{hasPhotos ? (
<div className="space-y-4">
<div className="text-sm text-gray-400">
Total Photos: {photos.length + existingPhotos.length}
{(mainPhotoIndex >= 0) && (
<Badge variant="outline" className="ml-2">
Main photo selected
</Badge>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{/* Existing Photos */}
{existingPhotos.map((photo, index) => (
<div key={`existing-${index}`} className="relative">
<img
src={`${API_BASE_URL}${photo.file_path}`}
alt={`Photo ${index + 1}`}
className="w-full h-24 object-cover rounded border border-gray-600"
/>
{mainPhotoIndex === index && (
<Badge className="absolute top-1 left-1 text-xs bg-[#9B2335]">
Main
</Badge>
)}
{captions[index] && (
<p className="text-xs text-gray-400 mt-1 truncate">
{captions[index]}
</p>
)}
</div>
))}
{/* New Photos */}
{photoPreviews.map((preview, index) => (
<div key={`new-${index}`} className="relative">
<img
src={preview}
alt={`New photo ${index + 1}`}
className="w-full h-24 object-cover rounded border border-gray-600"
/>
{mainPhotoIndex === (existingPhotos.length + index) && (
<Badge className="absolute top-1 left-1 text-xs bg-[#9B2335]">
Main
</Badge>
)}
{captions[existingPhotos.length + index] && (
<p className="text-xs text-gray-400 mt-1 truncate">
{captions[existingPhotos.length + index]}
</p>
)}
</div>
))}
</div>
</div>
) : (
<p className="text-gray-400 text-sm">No photos uploaded</p>
)}
</ReviewSection>
</div>
<div className="mt-8 p-4 bg-blue-900/20 border border-blue-500/30 rounded-lg">
<div className="flex items-start space-x-3">
<div className="text-blue-400 mt-0.5"></div>
<div>
<h4 className="text-blue-400 font-medium">Ready to Submit</h4>
<p className="text-blue-300 text-sm mt-1">
Please review all the information above. Once you submit,
{" "}this record will be saved to the database.
</p>
</div>
</div>
</div>
</div>
)
}
export default ReviewStep

View File

@ -0,0 +1,89 @@
import React from 'react'
import { CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { User, MapPin, FileText, Home, Users, Shield, Camera } from 'lucide-react'
const steps = ["Personal Details", "Immigration History", "Naturalization Records", "Residence History", "Family Information", "Internment Records", "Photos & Documents"]
const stepDescriptions = [
"Enter basic personal information and identification details",
"Record immigration and arrival information in Australia",
"Document citizenship and naturalization records",
"Track residential history and location changes",
"Add family members and relationship details",
"Record internment and detention history",
"Upload photos and supporting documents",
]
const stepIcons = [User, MapPin, FileText, Home, Users, Shield, Camera]
interface StepperHeaderProps {
currentStep: number
isEditMode: boolean
}
const StepperHeader: React.FC<StepperHeaderProps> = ({ currentStep, isEditMode }) => {
return (
<CardHeader className="bg-gray-800 p-8 border-b border-gray-700">
<div className="flex justify-between items-start mb-6">
<div>
<CardTitle className="text-xl md:text-2xl font-bold text-white">
{isEditMode ? `Edit ${steps[currentStep]}` : steps[currentStep]}
</CardTitle>
<CardDescription className="text-gray-400 mt-1">
{stepDescriptions[currentStep]}
</CardDescription>
</div>
<Badge variant="outline" className="text-sm border-gray-700 text-gray-300">
Step {currentStep + 1} of {steps.length}
</Badge>
</div>
<div className="mt-6">
<div className="flex justify-between text-sm text-gray-400 mb-2">
<span>Progress</span>
<span>{Math.round((currentStep / (steps.length - 1)) * 100)}% Complete</span>
</div>
<Progress
value={(currentStep / (steps.length - 1)) * 100}
className="h-2 bg-gray-700"
style={
{
"--progress-foreground": "#9B2335",
"--progress-background": "#374151",
} as React.CSSProperties
}
/>
</div>
<div className="flex justify-between mt-4 overflow-x-auto">
{steps.map((step, index) => {
const StepIcon = stepIcons[index]
return (
<div key={index} className="flex flex-col items-center min-w-0 flex-1">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center mb-2 transition-all duration-200 ${
index <= currentStep
? "bg-[#9B2335] text-white shadow-lg"
: "bg-gray-800 text-gray-500 border border-gray-700"
}`}
>
<StepIcon className="w-5 h-5" />
</div>
<span
className={`text-xs text-center px-1 transition-colors duration-200 ${
index <= currentStep ? "text-[#9B2335] font-medium" : "text-gray-500"
}`}
>
{step}
</span>
</div>
)
})}
</div>
</CardHeader>
)
}
export default StepperHeader

View File

@ -0,0 +1,81 @@
import React from 'react'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight, Save } from 'lucide-react'
interface StepperNavigationProps {
currentStep: number
totalSteps: number
isSubmitting: boolean
isEditMode: boolean
onPrevious: () => void
onNext: () => void
onSubmit: () => void
}
const StepperNavigation: React.FC<StepperNavigationProps> = ({
currentStep,
totalSteps,
isSubmitting,
isEditMode,
onPrevious,
onNext,
onSubmit,
}) => {
return (
<div className="bg-gray-800 px-8 py-6 border-t border-gray-700">
<div className="flex justify-between items-center">
<Button
variant="outline"
disabled={currentStep === 0}
onClick={onPrevious}
className="flex items-center gap-2 border-gray-700 text-gray-300 hover:bg-gray-700 hover:text-white disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<div className="flex items-center gap-2 text-sm text-gray-400">
<span>Step {currentStep + 1} of {totalSteps}</span>
</div>
{currentStep < totalSteps - 2 ? (
<Button
onClick={onNext}
className="flex items-center gap-2 bg-[#9B2335] hover:bg-[#9B2335]/90 shadow-lg"
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
) : currentStep === totalSteps - 2 ? (
<Button
onClick={onNext}
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 shadow-lg"
>
Review
<ChevronRight className="w-4 h-4" />
</Button>
) : (
<Button
onClick={onSubmit}
disabled={isSubmitting}
className="flex items-center gap-2 bg-green-600 hover:bg-green-700 shadow-lg"
>
{isSubmitting ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Saving...
</>
) : (
<>
<Save className="w-4 h-4" />
{isEditMode ? "Update Record" : "Submit Form"}
</>
)}
</Button>
)}
</div>
</div>
)
}
export default StepperNavigation

View File

@ -13,6 +13,8 @@ import { toast } from "react-hot-toast"
import apiService from "@/services/apiService"
import Header from "@/components/layout/Header"
import Sidebar from "@/components/layout/Sidebar"
import { UserSchema } from "@/schemas/userSchema"
import { z } from "zod"
export default function UserCreate() {
const navigate = useNavigate()
@ -23,6 +25,7 @@ export default function UserCreate() {
password: "",
password_confirmation: "",
})
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
@ -30,19 +33,50 @@ export default function UserCreate() {
...prev,
[name]: value,
}))
// Clear error for this field when user types
if (validationErrors[name]) {
setValidationErrors(prev => {
const updated = { ...prev }
delete updated[name]
return updated
})
}
}
const validateForm = () => {
try {
UserSchema.parse(formData)
setValidationErrors({})
return true
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {}
error.errors.forEach((err) => {
const path = err.path.join('.')
errors[path] = err.message
})
setValidationErrors(errors)
return false
}
return false
}
}
const getFieldError = (fieldName: string) => {
return validationErrors[fieldName] || null
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
// Basic validation
if (!formData.name || !formData.email || !formData.password) {
toast.error("Please fill in all required fields")
return
// Zod validation
if (!validateForm()) {
// Show the first validation error as a toast
const firstError = Object.values(validationErrors)[0]
if (firstError) {
toast.error(firstError)
}
if (formData.password !== formData.password_confirmation) {
toast.error("Passwords don't match")
return
}
@ -96,8 +130,11 @@ export default function UserCreate() {
value={formData.name}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.name ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('name') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('name')}</p>
)}
</div>
<div className="space-y-2">
@ -112,8 +149,11 @@ export default function UserCreate() {
value={formData.email}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.email ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('email') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('email')}</p>
)}
</div>
<div className="border-t border-gray-800 pt-6">
@ -131,8 +171,11 @@ export default function UserCreate() {
value={formData.password}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('password') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('password')}</p>
)}
</div>
<div className="space-y-2">
@ -147,12 +190,27 @@ export default function UserCreate() {
value={formData.password_confirmation}
onChange={handleInputChange}
required
className="bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500"
className={`bg-gray-800 border-gray-700 text-white focus:border-[#9B2335] placeholder:text-gray-500 ${validationErrors.password_confirmation ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
/>
{getFieldError('password_confirmation') && (
<p className="text-red-400 text-sm mt-1">{getFieldError('password_confirmation')}</p>
)}
</div>
</div>
</div>
</div>
{/* Display validation errors summary if needed */}
{Object.keys(validationErrors).length > 0 && (
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg">
<h4 className="text-red-400 font-medium mb-2">Please fix the following errors:</h4>
<ul className="text-sm text-red-300 space-y-1">
{Object.entries(validationErrors).map(([field, error]) => (
<li key={field}> {error}</li>
))}
</ul>
</div>
)}
</CardContent>
<CardFooter className="flex justify-end gap-3 pt-2 pb-6 px-6 border-t border-gray-800">

View File

@ -1,85 +1,35 @@
"use client"
import type React from "react"
import { Link } from "react-router-dom"
import {
Search,
Mail,
Phone,
MapPin,
Facebook,
Twitter,
Instagram,
Youtube,
Clock,
Users,
Database,
Award,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
import { Card, CardContent } from "@/components/ui/card"
import SearchForm from "./SearchForm"
import SearchForm from "@/components/home/SearchForm"
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import ApiService from "@/services/apiService"
import type { Person } from "@/types/api"
export default function Home() {
const [currentSlide, setCurrentSlide] = useState(0)
export default function Index() {
const navigate = useNavigate()
const [total, setTotal] = useState(0)
const [migrants, setMigrants] = useState<Person[]>([])
const API_BASE_URL = "http://localhost:8000"
useEffect(() => {
async function fetchData() {
const response = await ApiService.getMigrants(1, 10)
const response = await ApiService.getMigrants(1, 6)
setMigrants(response.data || [])
setTotal(response.total || 0)
}
fetchData()
}, [])
const backgroundImages = [
{ src: `${import.meta.env.BASE_URL}assets/slide1.avif`, alt: "Italian countryside landscape" },
{ src: `${import.meta.env.BASE_URL}assets/slide2.avif`, alt: "Vintage Italian architecture" },
{ src: `${import.meta.env.BASE_URL}assets/slide3.avif`, alt: "Italian coastal town" },
{ src: `${import.meta.env.BASE_URL}assets/slide4.avif`, alt: "Italian countryside with vineyards" },
];
const galleryImages = [
{
src: "https://images.unsplash.com/photo-1523906834658-6e24ef2386f9?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian countryside village",
},
{
src: "https://images.unsplash.com/photo-1515542622106-78bda8ba0e5b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian landscape",
},
{
src: "https://images.unsplash.com/photo-1552832230-c0197dd311b5?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian vineyards",
},
{
src: "https://images.unsplash.com/photo-1516483638261-f4dbaf036963?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian coastal town",
},
{
src: "https://images.unsplash.com/photo-1581833971358-2c8b550f87b3?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=800&q=80",
alt: "Italian traditional building",
},
]
useEffect(() => {
const timer = setInterval(() => {
setCurrentSlide((prev) => (prev + 1) % backgroundImages.length)
}, 5000)
return () => clearInterval(timer)
}, [backgroundImages.length])
// Smooth scroll to contact section
const scrollToContact = (e: React.MouseEvent) => {
e.preventDefault()
const contactSection = document.getElementById("contact")
@ -93,141 +43,129 @@ export default function Home() {
return (
<div className="flex flex-col min-h-screen">
<header className="absolute top-0 left-0 right-0 z-50 border-b border-white/20 bg-black/20 backdrop-blur-sm">
{/* Header */}
<header className="border-b">
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
<Link to="/" className="flex items-center gap-2">
<span className="text-xl font-bold text-white">Italian Migrants NT</span>
<span className="text-xl font-bold text-[#9B2335]">Italian Migrants NT</span>
</Link>
<nav className="hidden md:flex gap-6">
<Link to="/" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
<Link to="/" className="text-sm font-medium hover:underline underline-offset-4">
Home
</Link>
<a href="#about" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
<a href="#about" className="text-sm font-medium hover:underline underline-offset-4">
About
</a>
<a href="#stories" className="text-sm font-medium text-white hover:text-white/80 transition-colors">
<a href="#stories" className="text-sm font-medium hover:underline underline-offset-4">
Stories
</a>
<a
href="#contact"
onClick={scrollToContact}
className="text-sm font-medium text-white hover:text-white/80 transition-colors"
className="text-sm font-medium hover:underline underline-offset-4"
>
Contact
</a>
</nav>
<Button variant="outline" size="icon" className="md:hidden border-white/20 text-white hover:bg-white/10">
<Search className="h-4 w-4" />
<span className="sr-only">Search</span>
</Button>
</div>
</header>
<main className="flex-1">
{/* Hero Section with Background Carousel and Search */}
<section className="relative w-full h-screen flex items-center justify-center overflow-hidden">
{/* Background Carousel */}
<div className="absolute inset-0">
{backgroundImages.map((image, index) => (
<div
key={index}
className={`absolute inset-0 transition-opacity duration-1000 ${index === currentSlide ? "opacity-100" : "opacity-0"
}`}
>
<img src={image.src || `${import.meta.env.BASE_URL}assets/placeholder.svg`} alt={image.alt} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-black/60" />
</div>
))}
</div>
{/* Hero Section with Search Form */}
<section className="w-full py-12 md:py-16 lg:py-20 bg-[#E8DCCA]">
{/* No animated background elements */}
{/* Centered Content with Search */}
<div className="relative z-10 text-center text-white px-4 md:px-6 max-w-5xl mx-auto">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tighter font-serif mb-4">
Find Your Italian Heritage
<div className="container px-4 md:px-6">
<div className="grid lg:grid-cols-2 gap-12 items-center">
{/* Left side - Hero content */}
<div className="text-left space-y-6">
<h1 className="text-4xl md:text-6xl lg:text-7xl font-bold font-serif text-[#9B2335] mb-6">
Discover Your
<span className="block">
Italian Heritage
</span>
</h1>
<p className="text-lg md:text-xl lg:text-2xl mb-8 text-white/90 max-w-3xl mx-auto leading-relaxed">
Search our comprehensive database of Italian migrants to the Northern Territory. Discover family
histories, personal stories, and cultural contributions spanning over a century.
<p className="text-lg md:text-xl text-muted-foreground max-w-xl leading-relaxed">
Search our comprehensive database of Italian migrants to the Northern Territory.
Uncover family histories, personal stories, and cultural contributions spanning over a century.
</p>
{/* Main Search Form */}
<div className="bg-white/95 backdrop-blur-sm rounded-2xl p-6 md:p-8 shadow-2xl mb-8 max-w-4xl mx-auto">
<h2 className="text-2xl md:text-3xl font-bold text-[#9B2335] mb-6 font-serif">Search Migrant Database</h2>
<SearchForm />
{/* Quick Stats */}
<div className="grid grid-cols-3 gap-4 py-6">
<div className="text-center bg-white rounded-lg p-4 border shadow-sm">
<div className="text-2xl font-bold text-[#9B2335]">{total.toLocaleString()}</div>
<div className="text-gray-600 text-sm">Records</div>
</div>
<div className="text-center bg-white rounded-lg p-4 border shadow-sm">
<div className="text-2xl font-bold text-[#9B2335]">100+</div>
<div className="text-gray-600 text-sm">Years</div>
</div>
<div className="text-center bg-white rounded-lg p-4 border shadow-sm">
<div className="text-2xl font-bold text-[#9B2335]">156</div>
<div className="text-gray-600 text-sm">Regions</div>
</div>
</div>
</div>
{/* Right side - Search Form */}
<div className="bg-white rounded-lg p-8 border shadow-md">
<div className="space-y-6">
<div className="text-center">
<h2 className="text-2xl font-bold text-[#9B2335] mb-2">Search Database</h2>
<p className="text-muted-foreground">Find your Italian ancestors</p>
</div>
<SearchForm />
<div className="text-center">
<Button
size="lg"
variant="outline"
onClick={() => navigate("/search-results")}
className="border-white text-black hover:bg-white hover:text-black px-8 py-3 text-lg"
className="w-full bg-[#01796F] hover:bg-[#015a54] text-white"
>
Browse All Records
</Button>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-12 text-center">
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<div className="text-3xl font-bold text-white">{total.toLocaleString()}</div>
<div className="text-white/80">Migrant Records</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<div className="text-3xl font-bold text-white">1880-1980</div>
<div className="text-white/80">Years Covered</div>
</div>
<div className="bg-white/10 backdrop-blur-sm rounded-lg p-4">
<div className="text-3xl font-bold text-white">156</div>
<div className="text-white/80">Italian Regions</div>
</div>
</div>
</div>
{/* Carousel Indicators */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 flex space-x-2">
{backgroundImages.map((_, index) => (
<button
key={index}
onClick={() => setCurrentSlide(index)}
className={`w-3 h-3 rounded-full transition-all ${index === currentSlide ? "bg-white" : "bg-white/50"}`}
/>
))}
</div>
</div>
</section>
{/* Quick Search Tips */}
<section className="w-full py-12 bg-[#E8DCCA]">
{/* Search Tips Section */}
<section className="w-full py-16 bg-gray-100">
<div className="container px-4 md:px-6">
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold text-[#9B2335] font-serif mb-4">Search Tips</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-4">Search Tips</h2>
<p className="text-gray-600 max-w-2xl mx-auto">
Get the most out of your search with these helpful tips
</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto">
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="text-center p-6 bg-white rounded-lg border shadow-sm">
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
<Search className="h-6 w-6 text-white" />
</div>
<h3 className="font-semibold text-[#9B2335] mb-2">Name Variations</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-gray-600">
Try different spellings and shortened versions of names as they may have been anglicized upon arrival.
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="text-center p-6 bg-white rounded-lg border shadow-sm">
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white font-bold">📅</span>
<Clock className="h-6 w-6 text-white" />
</div>
<h3 className="font-semibold text-[#9B2335] mb-2">Date Ranges</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-gray-600">
Use broader date ranges as exact arrival dates may not always be recorded accurately.
</p>
</div>
<div className="text-center p-6 bg-white rounded-lg shadow-sm">
<div className="text-center p-6 bg-white rounded-lg border shadow-sm">
<div className="w-12 h-12 bg-[#01796F] rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-white font-bold">🗺</span>
<MapPin className="h-6 w-6 text-white" />
</div>
<h3 className="font-semibold text-[#9B2335] mb-2">Regional Search</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-gray-600">
Search by Italian region or province if you know your family's origin to narrow results.
</p>
</div>
@ -235,61 +173,50 @@ export default function Home() {
</div>
</section>
<section id="stories" className="w-full py-12 md:py-24 lg:py-32">
{/* Featured Stories */}
<section id="stories" className="w-full py-16 bg-white">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl font-serif text-[#1A2A57]">
Featured Stories
</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed">
Discover some of the remarkable personal journeys found in our database. Each story represents
courage, determination, and the pursuit of a better life.
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-4">Featured Stories</h2>
<p className="text-gray-600 max-w-3xl mx-auto text-lg">
Discover remarkable personal journeys found in our database. Each story represents courage, determination, and the pursuit of a better life.
</p>
</div>
</div>
<div className="mx-auto max-w-5xl py-12">
<Carousel className="w-full">
<CarouselContent>
{migrants.map((person) => {
// Find the profile photo
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
{migrants.slice(0, 6).map((person) => {
const profilePhoto = person.photos?.find((photo) => photo.is_profile_photo)
return (
<CarouselItem key={person.person_id} className="md:basis-1/2 lg:basis-1/2">
<div className="p-2">
<Card className="h-full">
<Card key={person.person_id} className="overflow-hidden pt-0">
<CardContent className="p-6">
<div className="space-y-4">
<div className="aspect-video overflow-hidden rounded-lg">
<div className="aspect-square overflow-hidden rounded-xl">
<img
src={
profilePhoto
? `${API_BASE_URL}${profilePhoto.file_path}`
: "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1000&q=80"
? `${import.meta.env.BASE_URL}${profilePhoto.file_path}`
: `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`
}
alt={`Portrait of ${person.full_name || person.surname || "Unnamed"}`}
className="object-cover w-full h-full"
className="object-cover w-full h-full hover:scale-105 transition-transform duration-300"
/>
</div>
<div className="space-y-2">
<h3 className="text-xl font-bold font-serif text-[#9B2335]">
<h3 className="text-lg font-bold text-[#9B2335]">
{person.full_name || person.surname || "Unnamed"}
</h3>
<p className="text-sm text-[#01796F] font-medium">
<p className="text-sm font-medium">
Arrived{" "}
{person.migration?.date_of_arrival_nt
? new Date(person.migration.date_of_arrival_nt).getFullYear()
: "Unknown"}
</p>
<p className="text-muted-foreground text-sm leading-relaxed">
{person.additional_notes || "No story available."}
<p className="text-sm leading-relaxed line-clamp-2">
{person.additional_notes || "A story of courage and determination in the Northern Territory."}
</p>
<Button
size="sm"
variant="outline"
className="mt-3 border-[#9B2335] text-[#9B2335] hover:bg-[#9B2335] hover:text-white"
className="w-full mt-3 bg-[#01796F] hover:bg-[#015a54] text-white"
onClick={() => navigate(`/migrant-profile/${person.person_id}`)}
>
View Full Record
@ -298,272 +225,41 @@ export default function Home() {
</div>
</CardContent>
</Card>
</div>
</CarouselItem>
)
})}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
</div>
</section>
<section id="about" className="w-full py-12 md:py-24 lg:py-32 bg-gray-50">
{/* About Section */}
<section id="about" className="w-full py-16 bg-[#E8DCCA]">
<div className="container px-4 md:px-6">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<div className="space-y-4 text-center lg:text-left">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl font-serif text-[#1A2A57]">
Preserving Our Heritage
</h2>
<p className="max-w-[600px] text-muted-foreground md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed mx-auto lg:mx-0">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl font-bold text-[#9B2335] font-serif mb-6">Preserving Our Heritage</h2>
<p className="text-gray-600 text-lg leading-relaxed mb-8">
This digital archive aims to preserve and celebrate the contributions of Italian migrants to the
Northern Territory. By documenting their stories, photographs, and historical records, we ensure that
their legacy continues for generations to come.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center lg:justify-start">
<Button className="bg-[#01796F] hover:bg-[#015a54] text-white">Contribute a Story</Button>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button className="bg-[#01796F] hover:bg-[#015a54] text-white">
Contribute a Story
</Button>
<Button
variant="outline"
className="border-[#9B2335] text-[#9B2335] hover:bg-[#9B2335] hover:text-white"
className="border-[#9B2335] text-[#9B2335] hover:bg-gray-100"
>
View Gallery
</Button>
</div>
</div>
<div className="w-full max-w-[500px] mx-auto">
<Carousel className="w-full">
<CarouselContent>
{galleryImages.map((image, index) => (
<CarouselItem key={index} className="md:basis-1/2">
<div className="p-1">
<div className="overflow-hidden rounded-xl aspect-square">
<img
src={image.src || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
alt={image.alt}
className="object-cover w-full h-full hover:scale-105 transition-transform duration-300"
/>
</div>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
</div>
</div>
</div>
</section>
</main>
{/* Enhanced Footer with Contact Information */}
<footer id="contact" className="bg-[#1A2A57] text-white">
<div className="container px-4 md:px-6">
{/* Main Footer Content */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8 py-12">
{/* About Section */}
<div className="space-y-4">
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Italian Migrants NT</h3>
<p className="text-sm text-gray-300 leading-relaxed">
Preserving and celebrating the rich heritage of Italian migrants to the Northern Territory through
digital archives, personal stories, and historical documentation.
</p>
<div className="flex items-center gap-2 text-sm text-gray-300">
<Award className="h-4 w-4 text-[#01796F]" />
<span>Heritage Australia Recognition</span>
</div>
</div>
{/* Contact Information */}
<div className="space-y-4">
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Contact Us</h3>
<div className="space-y-3">
<div className="flex items-start gap-3">
<MapPin className="h-4 w-4 text-[#01796F] mt-0.5 flex-shrink-0" />
<div className="text-sm text-gray-300">
<p>Northern Territory Archives Centre</p>
<p>Kelsey Crescent, Millner NT 0810</p>
<p>Australia</p>
</div>
</div>
<div className="flex items-center gap-3">
<Phone className="h-4 w-4 text-[#01796F]" />
<span className="text-sm text-gray-300">+61 8 8924 7677</span>
</div>
<div className="flex items-center gap-3">
<Mail className="h-4 w-4 text-[#01796F]" />
<a
href="mailto:heritage@italianmigrantsnt.org.au"
className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
>
heritage@italianmigrantsnt.org.au
</a>
</div>
<div className="flex items-center gap-3">
<Clock className="h-4 w-4 text-[#01796F]" />
<div className="text-sm text-gray-300">
<p>Mon-Fri: 9:00 AM - 5:00 PM</p>
<p>Sat: 10:00 AM - 2:00 PM</p>
</div>
</div>
</div>
</div>
{/* Quick Links */}
<div className="space-y-4">
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Quick Links</h3>
<nav className="space-y-2">
<Link
to="/search-results"
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
>
Search Database
</Link>
<Link to="#" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Contribute a Story
</Link>
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Photo Gallery
</Link>
<Link
to="/"
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
>
Research Help
</Link>
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Volunteer
</Link>
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Support Us
</Link>
</nav>
</div>
{/* Resources & Social */}
<div className="space-y-4">
<h3 className="text-lg font-bold font-serif text-[#E8DCCA]">Resources</h3>
<nav className="space-y-2">
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
FAQ
</Link>
<Link
to="/"
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
>
Research Guides
</Link>
<Link
to="/"
className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors"
>
Historical Timeline
</Link>
<Link to="/" className="block text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Partnerships
</Link>
</nav>
<div className="pt-4">
<h4 className="text-sm font-semibold text-[#E8DCCA] mb-3">Follow Us</h4>
<div className="flex gap-3">
<a
href="https://facebook.com/italianmigrantsnt"
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
aria-label="Follow us on Facebook"
>
<Facebook className="h-5 w-5" />
</a>
<a
href="https://twitter.com/italianmigrantsnt"
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
aria-label="Follow us on Twitter"
>
<Twitter className="h-5 w-5" />
</a>
<a
href="https://instagram.com/italianmigrantsnt"
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
aria-label="Follow us on Instagram"
>
<Instagram className="h-5 w-5" />
</a>
<a
href="https://youtube.com/italianmigrantsnt"
className="text-gray-300 hover:text-[#E8DCCA] transition-colors"
aria-label="Subscribe to our YouTube channel"
>
<Youtube className="h-5 w-5" />
</a>
</div>
</div>
</div>
</div>
{/* Database Stats */}
<div className="border-t border-gray-600 py-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center">
<div className="space-y-1">
<div className="flex items-center justify-center gap-2">
<Database className="h-5 w-5 text-[#01796F]" />
<span className="text-2xl font-bold text-[#E8DCCA]">{total.toLocaleString()}</span>
</div>
<p className="text-sm text-gray-300">Total Records</p>
</div>
<div className="space-y-1">
<div className="flex items-center justify-center gap-2">
<Users className="h-5 w-5 text-[#01796F]" />
<span className="text-2xl font-bold text-[#E8DCCA]">2,847</span>
</div>
<p className="text-sm text-gray-300">Families Documented</p>
</div>
<div className="space-y-1">
<div className="flex items-center justify-center gap-2">
<MapPin className="h-5 w-5 text-[#01796F]" />
<span className="text-2xl font-bold text-[#E8DCCA]">156</span>
</div>
<p className="text-sm text-gray-300">Italian Regions</p>
</div>
<div className="space-y-1">
<div className="flex items-center justify-center gap-2">
<Clock className="h-5 w-5 text-[#01796F]" />
<span className="text-2xl font-bold text-[#E8DCCA]">100+</span>
</div>
<p className="text-sm text-gray-300">Years of History</p>
</div>
</div>
</div>
{/* Bottom Footer */}
<div className="border-t border-gray-600 py-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex flex-col md:flex-row items-center gap-4 text-sm text-gray-300">
<p>&copy; {new Date().getFullYear()} Italian Migrants NT. All rights reserved.</p>
<div className="flex items-center gap-1">
<span>Powered by</span>
<span className="text-[#E8DCCA] font-semibold">Heritage Digital Archives</span>
</div>
</div>
<nav className="flex gap-6">
<a href="#" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Terms of Service
</a>
<a href="#" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Privacy Policy
</a>
<a href="#" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Accessibility
</a>
<a href="/login" className="text-sm text-gray-300 hover:text-[#E8DCCA] transition-colors">
Admin
</a>
</nav>
</div>
</div>
</div>
{/* Footer */}
<footer id="contact" className="bg-[#1A2A57] text-white border-t">
// ... keep existing code (footer content)
</footer>
</div>
)

View File

@ -7,7 +7,6 @@ import { MapPin, Calendar, User, Home, Ship, ImageIcon, FileText } from 'lucide-
import apiService from '@/services/apiService';
import type { Person } from '@/types/api';
import { formatDate } from '@/utils/date';
const API_BASE_URL = "http://localhost:8000";
export default function MigrantProfile() {
const { id } = useParams<{ id: string }>();
@ -70,14 +69,10 @@ export default function MigrantProfile() {
src={profilePhoto && profilePhoto.file_path
? profilePhoto.file_path.startsWith('http')
? profilePhoto.file_path
: `${API_BASE_URL}${profilePhoto.file_path}`
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=600&width=450`}
alt={`${migrant.full_name || 'Migrant photo'}`}
: `${apiService.baseURL}${profilePhoto.file_path}`
: `${import.meta.env.BASE_URL}assets/placeholder.png`}
alt={`${migrant.full_name || 'Migrant profile'}`}
className="aspect-[3/4] object-cover w-full"
onError={(e) => {
// Handle image loading errors by setting a fallback
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.svg?height=600&width=450`;
}}
/>
</div>
<div className="flex justify-between">
@ -188,13 +183,12 @@ export default function MigrantProfile() {
src={photo.file_path
? photo.file_path.startsWith('http')
? photo.file_path
: `${API_BASE_URL}${photo.file_path}`
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=400&width=600`}
: `${apiService.baseURL}${photo.file_path}`
: `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`}
alt={photo.caption || "Migrant photo"}
className="aspect-video object-cover w-full"
onError={(e) => {
// Handle image loading errors
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.svg?height=400&width=600`;
e.currentTarget.src = `${import.meta.env.BASE_URL}assets/placeholder.png?height=400&width=600`;
}}
/>
</div>

View File

@ -1,7 +1,8 @@
import { Search, ChevronDown, ChevronUp } from 'lucide-react';
import { Input } from '../ui/input';
import { Button } from '../ui/button';
import { Label } from '../ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useSearch } from '@/hooks/useSearch';
const advancedSearchFields = [
@ -34,67 +35,86 @@ export default function SearchForm() {
};
return (
<div className="w-full max-w-6xl mx-auto p-4 space-y-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="w-full space-y-6">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Main Search */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<Input
className='text-[#000] font-bold'
id="searchTerm"
placeholder="Search by name..."
value={fields.searchTerm}
onChange={handleInputChange}
className="h-12 bg-white border-gray-300 focus:border-[#01796F] focus:ring-1 focus:ring-[#01796F] placeholder:text-gray-500 text-gray-900 rounded-md transition-all duration-300"
/>
<Button type="submit" className="bg-[#9B2335] hover:bg-[#7a1c2a] text-white">
</div>
<Button
type="submit"
className="h-12 px-6 bg-[#01796F] hover:bg-[#015a54] text-white font-medium rounded-md shadow-md transition-all duration-300 border-0"
>
<Search className="mr-2 h-4 w-4" />
Search
</Button>
</div>
<div className="flex items-center justify-center">
{/* Advanced Search Toggle */}
<div className="text-center">
<Button
type="button"
variant="ghost"
className="text-[#01796F]"
className="text-[#9B2335] hover:text-[#7a1c29] hover:bg-gray-100 rounded-md px-6 py-2 transition-all duration-300"
onClick={toggleAdvancedSearch}
>
{isAdvancedSearch ? (
<>
<ChevronUp className="mr-2 h-4 w-4" />
Simple Search
Hide Advanced Search
</>
) : (
<>
<ChevronDown className="mr-2 h-4 w-4" />
Advanced Search
Show Advanced Search
</>
)}
</Button>
</div>
{/* Advanced Search Fields */}
{isAdvancedSearch && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4">
<div className="bg-gray-50 rounded-md p-6 border border-gray-200 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{advancedSearchFields.map(({ id, label, placeholder }) => (
<div key={id} className="space-y-2">
<Label htmlFor={id}>{label}</Label>
<Label
htmlFor={id}
className="text-sm font-medium text-gray-700"
>
{label}
</Label>
<Input
id={id}
placeholder={placeholder}
value={fields[id as keyof typeof fields]}
onChange={handleInputChange}
className="bg-white border-gray-300 focus:border-[#01796F] focus:ring-1 focus:ring-[#01796F] placeholder:text-gray-500 text-gray-900 rounded-md transition-all duration-300"
/>
</div>
))}
<div className="space-y-2 md:col-span-2">
<Label>Actions</Label>
<Button type="button" variant="ghost" className="w-full" onClick={clearAllFields}>
</div>
<div className="flex justify-center pt-4">
<Button
type="button"
variant="outline"
className="bg-white border-gray-300 text-gray-700 hover:bg-gray-100 hover:border-gray-400 rounded-md transition-all duration-300"
onClick={clearAllFields}
>
Clear All Filters
</Button>
</div>
</div>
)}
</form>
</div>
);
}

View File

@ -75,7 +75,7 @@ export default function SearchResults() {
src={
migrant.profilePhoto
? `${API_BASE_URL}${migrant.profilePhoto.file_path}`
: `${import.meta.env.BASE_URL}assets/placeholder.svg?height=300&width=300`
: `${import.meta.env.BASE_URL}assets/placeholder.png`
}
alt={migrant.full_name || "Unknown"}
className="w-full h-full object-cover object-center transition-transform hover:scale-105"

View File

@ -19,10 +19,8 @@ export default function Sidebar() {
async function loadUser() {
try {
const currentUser = await apiService.fetchCurrentUser();
console.log("Fetched user:", currentUser);
setUser(currentUser);
} catch (error) {
console.error("Failed to fetch user info:", error);
}
}
loadUser();

View File

@ -255,7 +255,7 @@ export default function ProfileSettings() {
<div className="h-24 w-24 rounded-full bg-gray-200 overflow-hidden">
{profile.avatar ? (
<img
src={profile.avatar || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
src={profile.avatar || `${import.meta.env.BASE_URL}assets/placeholder.png`}
alt="Profile"
className="h-full w-full object-cover"
/>

View File

@ -1,45 +0,0 @@
"use client";
import { useState } from "react";
import { motion } from "framer-motion";
interface AnimatedImageProps {
src: string;
alt: string;
className?: string;
width?: number;
height?: number;
fill?: boolean;
}
export default function AnimatedImage({
src,
alt,
className = "",
width,
height,
fill = false,
}: AnimatedImageProps) {
const [isLoaded, setIsLoaded] = useState(false);
return (
<motion.div
className={`relative overflow-hidden ${
fill ? "h-full w-full" : ""
} ${className}`}
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.5 }}
>
<img
src={src || `${import.meta.env.BASE_URL}assets/placeholder.svg`}
alt={alt}
width={!fill ? width : undefined}
height={!fill ? height : undefined}
className={`object-cover ${fill ? "h-full w-full" : ""}`}
onLoad={() => setIsLoaded(true)}
loading="lazy"
/>
</motion.div>
);
}

View File

@ -0,0 +1,50 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import apiService from '@/services/apiService'
import { showErrorToast } from '@/utils/toast'
export const useMigrantData = (
populateFormData: (data: any) => void,
populatePhotoData: (data: any) => void
) => {
const { id } = useParams<{ id: string }>()
const [loading, setLoading] = useState(false)
const [initialDataLoaded, setInitialDataLoaded] = useState(false)
const isEditMode = Boolean(id)
useEffect(() => {
if (isEditMode && id && !initialDataLoaded) {
loadExistingData()
}
}, [id, isEditMode, initialDataLoaded])
const loadExistingData = async () => {
try {
setLoading(true)
const personId = Number.parseInt(id!, 10)
if (isNaN(personId)) {
throw new Error("Invalid person ID")
}
const migrantData = await apiService.getMigrantById(personId)
populateFormData(migrantData)
populatePhotoData(migrantData)
setInitialDataLoaded(true)
} catch (error) {
showErrorToast("Failed to load migrant data for editing.")
} finally {
setLoading(false)
}
}
return {
loading,
isEditMode,
initialDataLoaded,
setInitialDataLoaded: (loaded: boolean) => setInitialDataLoaded(loaded),
}
}

263
src/hooks/useMigrantForm.ts Normal file
View File

@ -0,0 +1,263 @@
import { useState, useCallback } from 'react'
import { z } from 'zod'
import type { Person, Migration, Naturalization, Residence, Family, Internment } from '@/types/api'
import { PersonSchema, MigrationSchema, NaturalizationSchema, ResidenceSchema, FamilySchema, InternmentSchema, MigrantFormSchema} from '@/schemas/migrantSchema'
const getInitialPersonState = (): Person => ({
surname: "",
christian_name: "",
date_of_birth: "",
place_of_birth: "",
date_of_death: "",
occupation: "",
additional_notes: "",
reference: "",
id_card_no: "",
person_id: 0,
})
const getInitialMigrationState = (): Migration => ({
date_of_arrival_aus: "",
date_of_arrival_nt: "",
arrival_period: "",
data_source: "",
})
const getInitialNaturalizationState = (): Naturalization => ({
date_of_naturalisation: "",
no_of_cert: "",
issued_at: "",
})
const getInitialResidenceState = (): Residence => ({
town_or_city: "",
home_at_death: "",
})
const getInitialFamilyState = (): Family => ({
names_of_parents: "",
names_of_children: "",
})
const getInitialInternmentState = (): Internment => ({
corps_issued: "",
interned_in: "",
sent_to: "",
internee_occupation: "",
internee_address: "",
cav: "",
})
export const useMigrantForm = () => {
const [person, setPerson] = useState<Person>(getInitialPersonState())
const [migration, setMigration] = useState<Migration>(getInitialMigrationState())
const [naturalization, setNaturalization] = useState<Naturalization>(getInitialNaturalizationState())
const [residence, setResidence] = useState<Residence>(getInitialResidenceState())
const [family, setFamily] = useState<Family>(getInitialFamilyState())
const [internment, setInternment] = useState<Internment>(getInitialInternmentState())
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({})
const [isFormValid, setIsFormValid] = useState(false)
const validateForm = useCallback(() => {
try {
const formData = {
person,
migration,
naturalization,
residence,
family,
internment,
}
MigrantFormSchema.parse(formData)
setValidationErrors({})
setIsFormValid(true)
return true
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {}
error.errors.forEach((err) => {
const path = err.path.join('.')
errors[path] = err.message
})
setValidationErrors(errors)
setIsFormValid(false)
return false
}
setIsFormValid(false)
return false
}
}, [person, migration, naturalization, residence, family, internment])
const validateSection = useCallback((section: 'person' | 'migration' | 'naturalization' | 'residence' | 'family' | 'internment') => {
try {
const schemas = {
person: PersonSchema,
migration: MigrationSchema,
naturalization: NaturalizationSchema,
residence: ResidenceSchema,
family: FamilySchema,
internment: InternmentSchema,
}
const data = {
person,
migration,
naturalization,
residence,
family,
internment,
}
schemas[section].parse(data[section])
// Clear errors for this section
setValidationErrors(prev => {
const updated = { ...prev }
Object.keys(updated).forEach(key => {
if (key.startsWith(section)) {
delete updated[key]
}
})
return updated
})
return true
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string> = {}
error.errors.forEach((err) => {
const path = `${section}.${err.path.join('.')}`
errors[path] = err.message
})
setValidationErrors(prev => ({ ...prev, ...errors }))
return false
}
return false
}
}, [person, migration, naturalization, residence, family, internment])
// Helper function to get error for a specific field
const getFieldError = useCallback((fieldPath: string) => {
return validationErrors[fieldPath] || null
}, [validationErrors])
// Helper function to check if a field has an error
const hasFieldError = useCallback((fieldPath: string) => {
return !!validationErrors[fieldPath]
}, [validationErrors])
const resetForm = useCallback(() => {
setPerson(getInitialPersonState())
setMigration(getInitialMigrationState())
setNaturalization(getInitialNaturalizationState())
setResidence(getInitialResidenceState())
setFamily(getInitialFamilyState())
setInternment(getInitialInternmentState())
setValidationErrors({})
setIsFormValid(false)
}, [])
const populateFormData = useCallback((migrantData: any) => {
const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return ""
try {
const date = new Date(dateString)
return date.toISOString().split("T")[0]
} catch {
return ""
}
}
setPerson({
surname: migrantData.surname || "",
christian_name: migrantData.christian_name || "",
date_of_birth: formatDate(migrantData.date_of_birth),
place_of_birth: migrantData.place_of_birth || "",
date_of_death: formatDate(migrantData.date_of_death),
occupation: migrantData.occupation || "",
additional_notes: migrantData.additional_notes || "",
reference: migrantData.reference || "",
id_card_no: migrantData.id_card_no || "",
person_id: migrantData.person_id || 0,
})
if (migrantData.migration) {
setMigration({
date_of_arrival_aus: formatDate(migrantData.migration.date_of_arrival_aus),
date_of_arrival_nt: formatDate(migrantData.migration.date_of_arrival_nt),
arrival_period: migrantData.migration.arrival_period || "",
data_source: migrantData.migration.data_source || "",
})
}
if (migrantData.naturalization) {
setNaturalization({
date_of_naturalisation: formatDate(migrantData.naturalization.date_of_naturalisation),
no_of_cert: migrantData.naturalization.no_of_cert || "",
issued_at: migrantData.naturalization.issued_at || "",
})
}
if (migrantData.residence) {
setResidence({
town_or_city: migrantData.residence.town_or_city || "",
home_at_death: migrantData.residence.home_at_death || "",
})
}
if (migrantData.family) {
setFamily({
names_of_parents: migrantData.family.names_of_parents || "",
names_of_children: migrantData.family.names_of_children || "",
})
}
if (migrantData.internment) {
setInternment({
corps_issued: migrantData.internment.corps_issued || "",
interned_in: migrantData.internment.interned_in || "",
sent_to: migrantData.internment.sent_to || "",
internee_occupation: migrantData.internment.internee_occupation || "",
internee_address: migrantData.internment.internee_address || "",
cav: migrantData.internment.cav || "",
})
}
setValidationErrors({})
setIsFormValid(false)
}, [])
return {
formData: {
person,
migration,
naturalization,
residence,
family,
internment,
},
setters: {
setPerson,
setMigration,
setNaturalization,
setResidence,
setFamily,
setInternment,
},
validation: {
validateForm,
validateSection,
validationErrors,
isFormValid,
getFieldError,
hasFieldError,
clearErrors: () => {
setValidationErrors({})
setIsFormValid(false)
},
},
resetForm,
populateFormData,
}
}

View File

@ -1,7 +1,7 @@
import { useEffect, useState, useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import apiService from "@/services/apiService";
import type { Person, Photo } from "@/types/api";
import type { Person } from "@/types/api";
const getSearchParamsObject = (params: URLSearchParams): Record<string, string> => {
const result: Record<string, string> = {};
@ -11,11 +11,9 @@ const getSearchParamsObject = (params: URLSearchParams): Record<string, string>
return result;
};
export function useMigrantsSearch(perPage = 10) {
const [searchParams] = useSearchParams();
const [migrants, setMigrants] = useState<Person[]>([]);
const [photosById, setPhotosById] = useState<Record<number, Photo[]>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
@ -27,48 +25,57 @@ export function useMigrantsSearch(perPage = 10) {
const hasActiveFilters = searchParams.toString().length > 0;
const fetchPhotosForMigrant = async (migrant: Person): Promise<Person> => {
if (migrant.person_id === undefined) {
console.warn("Missing person_id for migrant", migrant);
return { ...migrant, photos: [], profilePhoto: null };
}
try {
const details = await apiService.getMigrantById(migrant.person_id);
if (Array.isArray(details?.photos)) {
return {
...migrant,
photos: details.photos,
profilePhoto: details.photos.find((p) => p.is_profile_photo) || null,
};
}
// Fallback to direct photo fetch
const photos = await apiService.getPhotos(migrant.person_id);
return {
...migrant,
photos,
profilePhoto: photos.find((p) => p.is_profile_photo) || null,
};
} catch (err) {
console.error(`Failed to fetch photos for person ${migrant.person_id}`, err);
return { ...migrant, photos: [], profilePhoto: null };
}
};
const fetchMigrants = useCallback(async () => {
setLoading(true);
setError(null);
try {
const filters = getSearchParamsObject(searchParams);
const response = await apiService.getMigrants(page, perPage, filters);
const fetchedMigrants = response.data || [];
setMigrants(fetchedMigrants);
const rawMigrants = response.data || [];
const enrichedMigrants = await Promise.all(
rawMigrants.map((migrant: Person) => fetchPhotosForMigrant(migrant))
);
setMigrants(enrichedMigrants);
setPagination({
currentPage: response.current_page ?? 1,
totalPages: response.last_page ?? 1,
totalItems: response.total ?? 0,
});
// Fetch photos for each migrant
const photosMap: Record<number, Photo[]> = {};
await Promise.all(
(fetchedMigrants || []).map(async (migrant: Person) => {
try {
if (migrant.person_id === undefined) {
console.warn('Missing person_id for migrant', migrant);
migrant.photos = [];
migrant.profilePhoto = null;
return;
}
const photos = await apiService.getPhotos(migrant.person_id);
migrant.photos = photos; // Store all photos
migrant.profilePhoto = photos.find((photo) => photo.is_profile_photo) || null;
} catch (err) {
console.error(`Failed to fetch photos for person ${migrant.person_id}`, err);
migrant.photos = [];
migrant.profilePhoto = null;
}
})
);
setPhotosById(photosMap);
} catch (err) {
console.error("Failed to fetch migrants", err);
setError("Failed to fetch migrants data.");
} finally {
setLoading(false);
@ -93,7 +100,6 @@ export function useMigrantsSearch(perPage = 10) {
return {
migrants,
photosById, // expose photos by person_id
loading,
error,
pagination,

View File

@ -0,0 +1,144 @@
import { useState, useCallback } from 'react'
import type { ExistingPhoto } from '@/types/api'
export const usePhotoManagement = () => {
const [photos, setPhotos] = useState<File[]>([])
const [photoPreviews, setPhotoPreviews] = useState<string[]>([])
const [captions, setCaptions] = useState<string[]>([])
const [mainPhotoIndex, setMainPhotoIndex] = useState<number | null>(null)
const [existingPhotos, setExistingPhotos] = useState<ExistingPhoto[]>([])
const [removedPhotoIds, setRemovedPhotoIds] = useState<number[]>([])
const handlePhotoChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const selectedFiles = Array.from(e.target.files)
setPhotos(prev => [...prev, ...selectedFiles])
const newPreviews = selectedFiles.map(file => URL.createObjectURL(file))
setPhotoPreviews(prev => [...prev, ...newPreviews])
setCaptions(prev => {
const newCaptions = [...prev]
selectedFiles.forEach(() => newCaptions.push(''))
return newCaptions
})
if (mainPhotoIndex === null) {
setMainPhotoIndex(0)
}
}
e.target.value = ''
}, [mainPhotoIndex])
const updateCaption = useCallback((index: number, newCaption: string) => {
setCaptions(prev => {
const updated = [...prev]
updated[index] = newCaption
return updated
})
}, [])
const setAsMainPhoto = useCallback((index: number) => {
setMainPhotoIndex(index)
}, [])
const removeExistingPhoto = useCallback((index: number) => {
const photoToRemove = existingPhotos[index]
const wasMainPhoto = mainPhotoIndex === index
if (photoToRemove?.id) {
setRemovedPhotoIds(prev => [...prev, photoToRemove.id])
}
setExistingPhotos(prev => prev.filter((_, i) => i !== index))
setCaptions(prev => {
const newCaptions = [...prev]
newCaptions.splice(index, 1)
return newCaptions
})
if (wasMainPhoto) {
const remainingExistingPhotos = existingPhotos.length - 1
const totalRemainingPhotos = remainingExistingPhotos + photos.length
if (totalRemainingPhotos > 0) {
setMainPhotoIndex(0)
} else {
setMainPhotoIndex(null)
}
} else if (mainPhotoIndex !== null && index < mainPhotoIndex) {
setMainPhotoIndex(mainPhotoIndex - 1)
}
}, [existingPhotos, mainPhotoIndex, photos.length])
const removeNewPhoto = useCallback((photoIndex: number) => {
const totalIndex = existingPhotos.length + photoIndex
const wasMainPhoto = mainPhotoIndex === totalIndex
setPhotos(prev => prev.filter((_, i) => i !== photoIndex))
setPhotoPreviews(prev => prev.filter((_, i) => i !== photoIndex))
setCaptions(prev => {
const newCaptions = [...prev]
newCaptions.splice(totalIndex, 1)
return newCaptions
})
if (wasMainPhoto) {
const totalRemainingPhotos = existingPhotos.length + (photos.length - 1)
if (totalRemainingPhotos > 0) {
setMainPhotoIndex(0)
} else {
setMainPhotoIndex(null)
}
} else if (mainPhotoIndex !== null && mainPhotoIndex > totalIndex) {
setMainPhotoIndex(mainPhotoIndex - 1)
}
}, [existingPhotos.length, mainPhotoIndex, photos.length])
const populatePhotoData = useCallback((migrantData: any) => {
if (migrantData.photos && Array.isArray(migrantData.photos) && migrantData.photos.length > 0) {
const photoData = migrantData.photos as ExistingPhoto[]
setExistingPhotos(photoData)
const existingCaptions = photoData.map((photo: ExistingPhoto) => photo.caption || "")
setCaptions(existingCaptions)
const mainPhotoIdx = photoData.findIndex((photo: ExistingPhoto) => photo.is_profile_photo)
if (mainPhotoIdx !== -1) {
setMainPhotoIndex(mainPhotoIdx)
}
}
}, [])
const resetPhotos = useCallback(() => {
setPhotos([])
setPhotoPreviews([])
setCaptions([])
setMainPhotoIndex(null)
setExistingPhotos([])
setRemovedPhotoIds([])
}, [])
return {
photos,
photoPreviews,
captions,
mainPhotoIndex,
existingPhotos,
removedPhotoIds,
handlePhotoChange,
updateCaption,
setAsMainPhoto,
removeExistingPhoto,
removeNewPhoto,
setCaptions,
setMainPhotoIndex,
populatePhotoData,
resetPhotos,
}
}

View File

@ -1,211 +0,0 @@
import { useState } from "react"
import { z } from "zod"
import apiService from "@/services/apiService"
// Schema types
export interface PersonData {
surname: string
christian_name: string
date_of_birth: string
place_of_birth: string
date_of_death: string
occupation: string
additional_notes: string
reference: string
id_card_no: string
}
export interface MigrationData {
date_of_arrival_aus: string
date_of_arrival_nt: string
arrival_period: string
data_source: string
}
export interface NaturalizationData {
date_of_naturalisation: string
no_of_cert: string
issued_at: string
}
export interface ResidenceData {
town_or_city: string
home_at_death: string
}
export interface FamilyData {
names_of_parents: string
names_of_children: string
}
export interface InternmentData {
corps_issued: string
interned_in: string
sent_to: string
internee_occupation: string
internee_address: string
cav: string
}
// Zod schemas for validation
export const personSchema = z.object({
surname: z.string().min(1, "Surname is required"),
christian_name: z.string().min(1, "Christian Name is required"),
date_of_birth: z.string().min(1, "Date of Birth is required"),
place_of_birth: z.string().min(1, "Place of Birth is required"),
date_of_death: z.string().min(1, "Date of Death is required"),
occupation: z.string().min(1, "Occupation is required"),
additional_notes: z.string().min(1, "Notes are required"),
reference: z.string().min(1, "Reference is required"),
id_card_no: z.string().min(1, "ID Card No is required"),
})
export const migrationSchema = z.object({
date_of_arrival_aus: z.string().min(1, "Date of Arrival in Australia is required"),
date_of_arrival_nt: z.string().min(1, "Date of Arrival in NT is required"),
arrival_period: z.string().min(1, "Arrival Period is required"),
data_source: z.string().min(1, "Data Source is required"),
})
export const naturalizationSchema = z.object({
date_of_naturalisation: z.string().min(1, "Date of Naturalisation is required"),
no_of_cert: z.string().min(1, "Number of Certificate is required"),
issued_at: z.string().min(1, "Issued At is required"),
})
export const residenceSchema = z.object({
town_or_city: z.string().min(1, "Town or City is required"),
home_at_death: z.string().min(1, "Home at Death is required"),
})
export const familySchema = z.object({
names_of_parents: z.string().min(1, "Names of Parents are required"),
names_of_children: z.string().min(1, "Names of Children are required"),
})
export const internmentSchema = z.object({
corps_issued: z.string().min(1, "Corps Issued is required"),
interned_in: z.string().min(1, "Interned In is required"),
sent_to: z.string().min(1, "Sent To is required"),
internee_occupation: z.string().min(1, "Internee Occupation is required"),
internee_address: z.string().min(1, "Internee Address is required"),
cav: z.string().min(1, "CAV is required"),
})
interface UseTabsPaneFormSubmitParams {
person: PersonData
migration: MigrationData
naturalization: NaturalizationData
residence: ResidenceData
family: FamilyData
internment: InternmentData
photos: File[]
captions: string[]
existingPhotos: any[]
mainPhotoIndex: number | null
isEditMode: boolean
id?: string
onReset: () => void
}
export const useTabsPaneFormSubmit = ({
person,
migration,
naturalization,
residence,
family,
internment,
photos,
captions,
existingPhotos,
mainPhotoIndex,
isEditMode,
id,
onReset,
}: UseTabsPaneFormSubmitParams) => {
const [loading, setLoading] = useState(false)
const handleSubmit = async () => {
try {
setLoading(true)
// Validate all form data using zod schemas
personSchema.parse(person)
migrationSchema.parse(migration)
naturalizationSchema.parse(naturalization)
residenceSchema.parse(residence)
familySchema.parse(family)
internmentSchema.parse(internment)
const formData = new FormData()
// Add person data
Object.entries(person).forEach(([key, value]) => formData.append(key, value))
// Add section data
Object.entries(migration).forEach(([key, value]) => formData.append(`migration[${key}]`, value))
Object.entries(naturalization).forEach(([key, value]) => formData.append(`naturalization[${key}]`, value))
Object.entries(residence).forEach(([key, value]) => formData.append(`residence[${key}]`, value))
Object.entries(family).forEach(([key, value]) => formData.append(`family[${key}]`, value))
Object.entries(internment).forEach(([key, value]) => formData.append(`internment[${key}]`, value))
photos.forEach((file) => formData.append("photos[]", file))
captions.forEach((caption, index) => formData.append(`captions[${index}]`, caption))
if (mainPhotoIndex !== null) {
formData.append("main_photo_index", mainPhotoIndex.toString())
formData.append("set_as_profile", "true")
// Determine if it's an existing photo or new photo
if (mainPhotoIndex < existingPhotos.length) {
// It's an existing photo
formData.append("profile_photo_id", existingPhotos[mainPhotoIndex].id.toString())
} else {
// It's a new photo
formData.append("profile_photo_index", (mainPhotoIndex - existingPhotos.length).toString())
}
}
// Add existing photo IDs that should be kept
existingPhotos.forEach((photo, index) => {
formData.append(`existing_photos[${index}]`, photo.id.toString())
})
let response
if (isEditMode && id) {
// Convert string ID to number for the API call
const personId = Number.parseInt(id, 10)
if (isNaN(personId)) {
throw new Error("Invalid person ID")
}
// Update existing migrant
response = await apiService.updateMigrant(personId, formData)
alert("Person updated successfully!")
} else {
// Create new migrant
response = await apiService.createMigrant(formData)
alert("Person created successfully!")
onReset()
}
console.log("Success:", response)
return response
} catch (err: any) {
if (err instanceof z.ZodError) {
alert("Validation error: " + err.errors.map((e) => e.message).join(", "))
} else {
console.error("Error:", err)
alert(`Failed to ${isEditMode ? "update" : "create"} person.`)
}
throw err
} finally {
setLoading(false)
}
}
return {
loading,
handleSubmit,
}
}

View File

@ -0,0 +1,55 @@
import { z } from 'zod'
export const PersonSchema = z.object({
surname: z.string().min(1, "Surname is required"),
christian_name: z.string().min(1, "Christian name is required"),
date_of_birth: z.string().optional(),
place_of_birth: z.string().optional(),
date_of_death: z.string().optional(),
occupation: z.string().optional(),
additional_notes: z.string().optional(),
reference: z.string().optional(),
id_card_no: z.string().optional(),
person_id: z.number(),
})
export const MigrationSchema = z.object({
date_of_arrival_aus: z.string().optional(),
date_of_arrival_nt: z.string().optional(),
arrival_period: z.string().optional(),
data_source: z.string().optional(),
})
export const NaturalizationSchema = z.object({
date_of_naturalisation: z.string().optional(),
no_of_cert: z.string().optional(),
issued_at: z.string().optional(),
})
export const ResidenceSchema = z.object({
town_or_city: z.string().optional(),
home_at_death: z.string().optional(),
})
export const FamilySchema = z.object({
names_of_parents: z.string().optional(),
names_of_children: z.string().optional(),
})
export const InternmentSchema = z.object({
corps_issued: z.string().optional(),
interned_in: z.string().optional(),
sent_to: z.string().optional(),
internee_occupation: z.string().optional(),
internee_address: z.string().optional(),
cav: z.string().optional(),
})
export const MigrantFormSchema = z.object({
person: PersonSchema,
migration: MigrationSchema,
naturalization: NaturalizationSchema,
residence: ResidenceSchema,
family: FamilySchema,
internment: InternmentSchema,
})

42
src/schemas/userSchema.ts Normal file
View File

@ -0,0 +1,42 @@
import { z } from 'zod'
// Schema for creating a new user - all fields required
export const UserSchema = z.object({
name: z.string().min(1, "Full name is required"),
email: z.string().email("Invalid email address").min(1, "Email is required"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number"),
password_confirmation: z.string()
}).refine((data) => data.password === data.password_confirmation, {
message: "Passwords don't match",
path: ["password_confirmation"]
})
// Schema for updating user - passwords optional but must meet requirements if provided
export const UserUpdateSchema = z.object({
name: z.string().min(1, "Full name is required"),
email: z.string().email("Invalid email address").min(1, "Email is required"),
current_password: z.string().min(1, "Current password is required"),
password: z
.string()
.min(8, "Password must be at least 8 characters")
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
.optional()
.or(z.literal('')),
password_confirmation: z.string().optional().or(z.literal(''))
}).refine((data) => {
// If password is provided, password_confirmation must match
if (data.password && data.password.length > 0) {
return data.password === data.password_confirmation
}
return true
}, {
message: "Passwords don't match",
path: ["password_confirmation"]
})

View File

@ -1,6 +1,10 @@
import axios, { type AxiosInstance } from "axios";
import type { DashboardResponse, Person, Photo, User } from "@/types/api";
/**
* ApiService handles all HTTP requests to the backend API using Axios.
* It includes authentication, user, migrant, and dashboard-related operations.
*/
class ApiService {
private api: AxiosInstance;
@ -8,17 +12,17 @@ class ApiService {
this.api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
headers: { "Content-Type": "application/json" },
withCredentials: true, // IMPORTANT
withCredentials: true,
});
// Request Interceptor
// Attach JWT token to request headers if available
this.api.interceptors.request.use((config) => {
const token = localStorage.getItem("token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response Interceptor
// Handle 401 Unauthorized responses by logging out the user
this.api.interceptors.response.use(
(res) => res,
(err) => {
@ -31,40 +35,42 @@ class ApiService {
}
);
}
get baseURL(): string {
return this.api.defaults.baseURL || "";
}
// --- AUTH ---
/** User authentication: login with email & password */
async login(params: { email: string; password: string }) {
// First get CSRF cookie
await this.api.get("/sanctum/csrf-cookie");
// Then login
return this.api.post("/api/login", params).then((res) => {
console.log("Token:", res.data.token);
localStorage.setItem("token", res.data.token);
localStorage.setItem("user", JSON.stringify(res.data.user));
return res.data;
});
}
/** User registration */
async register(params: { name: string; email: string; password: string }) {
return this.api.post("/api/register", params).then((res) => res.data);
}
/** Create a new user account */
async createUser(user: User) {
return this.api.post("/api/register", user).then((res) => res.data);
}
/** Update current user's account information */
async updateUser(user: User) {
return this.api.put("/api/user/account", user).then(res => res.data);
}
/** Fetch a list of all users */
async displayAllUsers() {
return this.api.get("/api/users").then(res => res.data.data);
}
/** Logout the current user and clear stored tokens */
async logout() {
return this.api.post("/api/logout").then((res) => {
localStorage.removeItem("token");
@ -72,49 +78,50 @@ class ApiService {
return res.data;
});
}
/** Fetch current user's details */
async fetchCurrentUser(): Promise<User> {
return this.api.get("/api/user").then((res) => res.data.data.user);
}
// --- MIGRANTS ---
/** Fetch a paginated list of migrants */
async getMigrants(page = 1, perPage = 10, filters = {}): Promise<Person> {
return this.api.get("/api/migrants", {
params: { page, per_page: perPage, ...filters },
}).then((res) => res.data.data);
}
/** Fetch migrants data by a specific URL (for pagination, filtering, etc.) */
async getMigrantsByUrl(url: string) {
return this.api.get(url).then((res) => res.data);
}
/** Fetch a migrant's details by ID */
async getMigrantById(id: string | number): Promise<Person> {
return this.api.get(`/api/migrants/${id}`).then((res) => res.data.data);
}
/** Create a new migrant entry with form data (multipart request) */
async createMigrant(formData: FormData): Promise<any> {
return this.api.post("/api/migrants", formData, {
headers: { "Content-Type": "multipart/form-data" },
}).then((res) => res.data.data);
}
/** Update a migrant entry by ID using form data (multipart request) */
async updateMigrant(personId: number, formData: FormData): Promise<any> {
// Use POST with method spoofing instead of PUT for better FormData compatibility
formData.append('_method', 'PUT');
return this.api.post(`/api/migrants/${personId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
}).then((res) => {
return res.data.data;
}).catch(error => {
throw error;
});
}).then((res) => res.data.data);
}
/** Delete a migrant entry by ID */
async deleteMigrant(id: string | number) {
return this.api.delete(`/api/migrants/${id}`).then((res) => res.data);
}
/** Fetch all photos for a specific migrant */
async getPhotos(id: number): Promise<Photo[]> {
return this.api.get(`/api/migrants/${id}/photos`).then((res) => {
const photosData = res.data.data.photos;
@ -122,12 +129,13 @@ class ApiService {
});
}
// --- DASHBOARD ---
/** Fetch dashboard statistics */
async getDashboardStats(): Promise<DashboardResponse> {
const response = await this.api.get<DashboardResponse>("/api/dashboard/stats");
return response.data;
}
/** Fetch recent activity logs */
async getRecentActivityLogs() {
return this.api.get("/api/activity-logs").then((res) => res.data.data);
}

View File

@ -0,0 +1,140 @@
import type { Person, Migration, Naturalization, Residence, Family, Internment, ExistingPhoto } from '@/types/api'
interface FormDataConfig {
person: Person
migration: Migration
naturalization: Naturalization
residence: Residence
family: Family
internment: Internment
photos: File[]
captions: string[]
mainPhotoIndex: number | null
existingPhotos: ExistingPhoto[]
removedPhotoIds: number[]
isEditMode: boolean
}
export const buildFormData = (config: FormDataConfig): FormData => {
const formData = new FormData()
const {
person,
migration,
naturalization,
residence,
family,
internment,
photos,
captions,
mainPhotoIndex,
existingPhotos,
removedPhotoIds,
isEditMode,
} = config
// Add person data
Object.entries(person).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
formData.append(key, String(value))
}
})
// Add migration data
if (Object.values(migration).some(v => v)) {
Object.entries(migration).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
formData.append(`migration[${key}]`, String(value))
}
})
}
// Add naturalization data
if (Object.values(naturalization).some(v => v)) {
Object.entries(naturalization).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
formData.append(`naturalization[${key}]`, String(value))
}
})
}
// Add residence data
if (Object.values(residence).some(v => v)) {
Object.entries(residence).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
formData.append(`residence[${key}]`, String(value))
}
})
}
// Add family data
if (Object.values(family).some(v => v)) {
Object.entries(family).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
formData.append(`family[${key}]`, String(value))
}
})
}
// Add internment data
if (Object.values(internment).some(v => v)) {
Object.entries(internment).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
formData.append(`internment[${key}]`, String(value))
}
})
}
// Add new photos
photos.forEach((photo) => {
formData.append('photos[]', photo)
})
// Add captions for new photos only
const newPhotoCaptions = captions.slice(existingPhotos.length)
newPhotoCaptions.forEach((caption, index) => {
formData.append(`captions[${index}]`, caption || '')
})
// Handle main photo logic for edit mode
if (isEditMode) {
// Add removed photo IDs
if (removedPhotoIds.length > 0) {
removedPhotoIds.forEach((photoId, index) => {
formData.append(`remove_photos[${index}]`, photoId.toString())
})
}
// Handle existing photo updates (captions)
existingPhotos.forEach((photo, index) => {
formData.append(`existing_photos[${index}][id]`, photo.id.toString())
formData.append(`existing_photos[${index}][caption]`, captions[index] || '')
})
// Handle main photo setting
if (mainPhotoIndex !== null) {
formData.append('set_as_profile', '1')
if (mainPhotoIndex < existingPhotos.length) {
// Main photo is an existing photo
const existingPhoto = existingPhotos[mainPhotoIndex]
formData.append('profile_photo_id', existingPhoto.id.toString())
} else {
// Main photo is a new photo
const newPhotoIndex = mainPhotoIndex - existingPhotos.length
formData.append('main_photo_index', newPhotoIndex.toString())
}
} else {
formData.append('set_as_profile', '0')
}
} else {
// Creating new migrant
if (mainPhotoIndex !== null) {
formData.append('set_as_profile', '1')
formData.append('main_photo_index', mainPhotoIndex.toString())
} else {
formData.append('set_as_profile', '0')
}
}
return formData
}