refactor: migrate to new API service and update search functionality

This commit is contained in:
mark 2025-05-17 22:14:50 +08:00
parent 61603a9175
commit 37a6be3504
16 changed files with 823 additions and 390 deletions

6
.env Normal file
View File

@ -0,0 +1,6 @@
# .env
VITE_API_URL=http://localhost:8000
# You can also create environment-specific files:
# .env.development
# .env.production

132
package-lock.json generated
View File

@ -15,6 +15,7 @@
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-toast": "^1.2.13",
"@tailwindcss/vite": "^4.1.6",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.11.0",
@ -2548,6 +2549,11 @@
"node": ">=10"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/autoprefixer": {
"version": "10.4.21",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
@ -2585,6 +2591,16 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2678,7 +2694,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
@ -2793,6 +2808,17 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -2900,6 +2926,14 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@ -2926,7 +2960,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
@ -2973,7 +3006,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -2982,7 +3014,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -2991,7 +3022,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0"
},
@ -2999,6 +3029,20 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
@ -3453,6 +3497,58 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@ -3527,7 +3623,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -3545,7 +3640,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
@ -3577,7 +3671,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
@ -3614,7 +3707,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
@ -3646,7 +3738,20 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
@ -3658,7 +3763,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
@ -4152,7 +4256,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
@ -4550,6 +4653,11 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -17,6 +17,7 @@
"@radix-ui/react-slot": "^1.2.2",
"@radix-ui/react-toast": "^1.2.13",
"@tailwindcss/vite": "^4.1.6",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.11.0",

View File

@ -1,5 +1,4 @@
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import HomePage from "./pages/HomePage";
import MigrantProfilePage from "./pages/MigrantProfilePage";
import NotFoundPage from "./pages/NotFoundPage";
import "./App.css";
@ -7,6 +6,7 @@ import LoginPage from "./components/LoginPage";
import Migrants from "./components/Migrants";
import ProfileSettings from "./components/ui/ProfileSettings";
import AdminDashboardPage from "./pages/AdminDashboardPage";
import HomePage from "./pages/HomePage";
function App() {
return (
@ -17,7 +17,7 @@ function App() {
<Route path="/admin" element={<AdminDashboardPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/migrant/:id" element={<MigrantProfilePage />} />
<Route path="/migrants/:id" element={<MigrantProfilePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Router>

View File

@ -29,7 +29,7 @@ export default function AdminDashboard() {
// Check if user is authenticated
const token = localStorage.getItem("adminToken")
if (!token) {
navigate("/admin/login")
navigate("/admin")
return
}
@ -62,7 +62,7 @@ export default function AdminDashboard() {
localStorage.removeItem("adminToken")
localStorage.removeItem("adminNavigation") // Clear navigation flag on logout
navigate("/admin/login")
navigate("/login")
}

View File

@ -46,14 +46,14 @@ export default function Migrants() {
localStorage.removeItem("adminNavigation") // Clear navigation flag on logout
navigate("/admin/login")
navigate("/login")
}
// Check authentication and load data
useEffect(() => {
const token = localStorage.getItem("adminToken")
if (!token) {
navigate("/admin/login")
navigate("/login")
return
}

View File

@ -1,69 +1,55 @@
"use client";
import { useState, type ChangeEvent, type FormEvent } from "react";
import { useState, useEffect, type ChangeEvent, type FormEvent } from "react";
import type { SearchParams } from "@/types/search";
import { Button } from "@/components/ui/button";
import { Search } from "lucide-react";
// Mock data for autocomplete
const ITALIAN_REGIONS = [
"Abruzzo",
"Basilicata",
"Calabria",
"Campania",
"Emilia-Romagna",
"Friuli-Venezia Giulia",
"Lazio",
"Liguria",
"Lombardy",
"Marche",
"Molise",
"Piedmont",
"Puglia",
"Sardinia",
"Sicily",
"Tuscany",
"Trentino-Alto Adige",
"Umbria",
"Valle d'Aosta",
"Veneto",
];
const NT_SETTLEMENTS = [
"Darwin",
"Alice Springs",
"Katherine",
"Tennant Creek",
"Nhulunbuy",
"Jabiru",
"Yulara",
"Borroloola",
"Pine Creek",
"Adelaide River",
];
interface SearchFormProps {
onSearch: (params: SearchParams) => void;
onReset?: () => void;
}
const SearchForm = ({ onSearch }: SearchFormProps) => {
const [formData, setFormData] = useState<SearchParams>({
// Default form data
const defaultData: SearchParams = {
firstName: "",
lastName: "",
ageAtMigration: "",
yearOfArrival: "",
regionOfOrigin: "all",
settlementLocation: "all",
});
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSelectChange = (e: ChangeEvent<HTMLSelectElement>) => {
// Form field definitions to make the JSX cleaner
const textFields = [
{ id: "firstName", label: "First Name (Christian Name)", type: "text" },
{ id: "lastName", label: "Last Name (Surname)", type: "text" },
{ id: "ageAtMigration", label: "Age at Migration", type: "number", min: 0, max: 120 },
{
id: "yearOfArrival",
label: "Date of Arrival in NT (Year)",
type: "number",
min: 1800,
max: new Date().getFullYear()
},
];
const selectFields = [
{ id: "regionOfOrigin", label: "Region of Origin", options: ["all"] },
{ id: "settlementLocation", label: "Settlement Location", options: ["all"] },
];
const SearchForm = ({ onSearch, onReset }: SearchFormProps) => {
const [formData, setFormData] = useState(defaultData);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(false);
}, []);
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
setFormData(prevData => ({ ...prevData, [name]: value }));
};
const handleSubmit = (e: FormEvent) => {
@ -72,125 +58,57 @@ const SearchForm = ({ onSearch }: SearchFormProps) => {
};
const handleReset = () => {
setFormData({
firstName: "",
lastName: "",
ageAtMigration: "",
yearOfArrival: "",
regionOfOrigin: "all",
settlementLocation: "all",
});
setFormData(defaultData);
if (onReset) onReset();
};
return (
<form onSubmit={handleSubmit} className="card p-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-2">
<label htmlFor="firstName" className="block text-sm font-medium">
First Name
{/* Text and number input fields */}
{textFields.map(({ id, label, type, min, max }) => (
<div key={id} className="space-y-2">
<label htmlFor={id} className="block text-sm font-medium">
{label}
</label>
<input
id="firstName"
name="firstName"
type="text"
value={formData.firstName}
onChange={handleInputChange}
placeholder="Enter first name"
id={id}
name={id}
type={type}
value={formData[id as keyof SearchParams] || ""}
onChange={handleChange}
placeholder={`Enter ${label.toLowerCase()}`}
min={min}
max={max}
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
))}
<div className="space-y-2">
<label htmlFor="lastName" className="block text-sm font-medium">
Last Name
</label>
<input
id="lastName"
name="lastName"
type="text"
value={formData.lastName}
onChange={handleInputChange}
placeholder="Enter last name"
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div className="space-y-2">
<label htmlFor="ageAtMigration" className="block text-sm font-medium">
Age at Migration
</label>
<input
id="ageAtMigration"
name="ageAtMigration"
type="number"
value={formData.ageAtMigration}
onChange={handleInputChange}
placeholder="Enter age"
min="0"
max="120"
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div className="space-y-2">
<label htmlFor="yearOfArrival" className="block text-sm font-medium">
Year of Arrival
</label>
<input
id="yearOfArrival"
name="yearOfArrival"
type="number"
value={formData.yearOfArrival}
onChange={handleInputChange}
placeholder="Enter year"
min="1800"
max={new Date().getFullYear()}
className="w-full p-2 border border-gray-300 rounded-md"
/>
</div>
<div className="space-y-2">
<label htmlFor="regionOfOrigin" className="block text-sm font-medium">
Region of Origin in Italy
{selectFields.map(({ id, label, options }) => (
<div key={id} className="space-y-2">
<label htmlFor={id} className="block text-sm font-medium">
{label}
</label>
<select
id="regionOfOrigin"
name="regionOfOrigin"
value={formData.regionOfOrigin}
onChange={handleSelectChange}
id={id}
name={id}
value={formData[id as keyof SearchParams] || "all"}
onChange={handleChange}
className="w-full p-2 border border-gray-300 rounded-md"
disabled={isLoading}
>
<option value="all">All Regions</option>
{ITALIAN_REGIONS.map((region) => (
<option key={region} value={region}>
{region}
{options.map(opt => (
<option key={opt} value={opt}>
{opt === "all" ? `All ${label.split(" ")[0]}s` : opt}
</option>
))}
</select>
{isLoading && (
<p className="text-xs text-gray-500">Loading {label.toLowerCase()}...</p>
)}
</div>
<div className="space-y-2">
<label
htmlFor="settlementLocation"
className="block text-sm font-medium"
>
Settlement Location in NT
</label>
<select
id="settlementLocation"
name="settlementLocation"
value={formData.settlementLocation}
onChange={handleSelectChange}
className="w-full p-2 border border-gray-300 rounded-md"
>
<option value="all">All Locations</option>
{NT_SETTLEMENTS.map((location) => (
<option key={location} value={location}>
{location}
</option>
))}
</select>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8 justify-end">
@ -205,6 +123,7 @@ const SearchForm = ({ onSearch }: SearchFormProps) => {
<Button
type="submit"
className="bg-gradient-to-r from-green-600 via-white to-red-600 text-gray-800 hover:from-green-700 hover:via-gray-100 hover:to-red-700 font-medium"
disabled={isLoading}
>
<Search className="mr-2 h-4 w-4" /> Search Records
</Button>

View File

@ -1,6 +1,6 @@
"use client";
import { Link } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import type { SearchResult } from "@/types/search";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -18,6 +18,9 @@ export default function SearchResults({
isLoading,
hasSearched = false,
}: SearchResultsProps) {
const navigate = useNavigate();
// Define animation variants
const container = {
hidden: { opacity: 0 },
show: {
@ -99,16 +102,15 @@ export default function SearchResults({
animate="show"
>
{results.map((person) => (
<motion.div key={person.id} variants={item}>
<Link to={`/migrant/${person.id}`} className="block h-full">
<motion.div key={person.person_id || person.id_card_no} variants={item} onClick={() => navigate(`/migrants/${person.person_id}`)}>
<div className="block h-full cursor-pointer">
<Card className="overflow-hidden hover:shadow-lg transition-shadow h-full border border-gray-200 group">
<div className="relative h-48 w-full overflow-hidden">
<AnimatedImage
src={
person.photoUrl ||
"/placeholder.svg?height=300&width=300"
}
alt={`${person.firstName} ${person.lastName}`}
alt=""
fill
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
@ -122,27 +124,31 @@ export default function SearchResults({
</div>
<CardHeader>
<CardTitle className="font-serif">
{person.firstName} {person.lastName}
{person.full_name}
</CardTitle>
<p className="text-sm text-gray-500">
Arrived {person.yearOfArrival} at age{" "}
{person.ageAtMigration}
{person.migration?.date_of_arrival_nt ?
`Arrived ${new Date(person.migration.date_of_arrival_nt).getFullYear()}` : 'Date unknown'}
</p>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p>
<span className="font-medium">From:</span>{" "}
{person.regionOfOrigin}, Italy
{person.place_of_birth || 'Unknown'}, Italy
</p>
<p>
<span className="font-medium">Settled in:</span>{" "}
{person.settlementLocation}, NT
{person.residence?.town_or_city || 'Unknown'}, NT
</p>
<p>
<span className="font-medium">Occupation:</span>{" "}
{person.occupation || 'Unknown'}
</p>
</div>
</CardContent>
</Card>
</Link>
</div>
</motion.div>
))}
</motion.div>

View File

@ -4,25 +4,69 @@ import { useState } from "react";
import SearchForm from "./SearchForm";
import SearchResults from "./SearchResults";
import type { SearchParams, SearchResult } from "../types/search";
import { searchMigrants } from "../services/migrantService";
import apiService from "../services/apiService";
import { Loader2 } from "lucide-react";
const SearchSection = () => {
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
const [noResultsFound, setNoResultsFound] = useState(false);
const handleSearch = async (params: SearchParams) => {
// Check if at least one search parameter is provided (not empty and not 'all')
const hasSearchCriteria = Object.entries(params).some(([_, value]) =>
value && value !== "" && value !== "all"
);
// If no search criteria provided, don't perform search
if (!hasSearchCriteria) {
setSearchResults([]);
setHasSearched(false);
setNoResultsFound(false);
return;
}
// Show loading indicator
setIsSearching(true);
setSearchResults([]);
try {
const results = await searchMigrants(params);
// Display a loading message in the UI before making the API call
// API call with small delay to ensure loader is visible
setTimeout(async () => {
try {
// Use apiService.searchPeople with exact matching
const results = await apiService.searchPeople(params);
setSearchResults(results);
setHasSearched(true);
// Check if the results array is empty
setNoResultsFound(results.length === 0);
// Log successful search
console.log(`Found ${results.length} exact match results using apiService.searchPeople`);
} catch (error) {
console.error("Error searching migrants:", error);
// In a real application, you would handle this error more gracefully
console.error("Error searching people:", error);
// Display user-friendly error message
setSearchResults([]);
setHasSearched(true);
setNoResultsFound(true);
} finally {
setIsSearching(false);
}
}, 500); // Short delay to ensure loader is visible
} catch (error) {
console.error("Unexpected error:", error);
setIsSearching(false);
setHasSearched(true);
}
};
const handleReset = () => {
setSearchResults([]);
setHasSearched(false);
setNoResultsFound(false);
};
return (
@ -31,9 +75,31 @@ const SearchSection = () => {
<h2 className="text-3xl md:text-4xl font-bold mb-8 text-center">
Search Historical Records
</h2>
<SearchForm onSearch={handleSearch} />
{(isSearching || hasSearched) && (
<SearchResults results={searchResults} isLoading={isSearching} />
<SearchForm onSearch={handleSearch} onReset={handleReset} />
{isSearching && (
<div className="flex justify-center my-8">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<span className="sr-only">Loading...</span>
</div>
)}
{hasSearched && !isSearching && noResultsFound && (
<div className="text-center py-12 my-8 bg-gray-100 rounded-lg border border-gray-200">
<h3 className="text-2xl font-semibold mb-4 font-serif">No Results Found</h3>
<p className="text-gray-500 mb-4">
We couldn't find any records matching your search criteria.
</p>
<p className="text-gray-600">
Try adjusting your search filters or using fewer criteria to broaden your results.
</p>
</div>
)}
{(hasSearched || isSearching) && (
<SearchResults
results={searchResults}
isLoading={isSearching}
hasSearched={hasSearched && !noResultsFound}
/>
)}
</div>
</section>

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -2,48 +2,173 @@
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getMigrantById } from "../services/migrantService";
import apiService from "../services/apiService";
import type { MigrantProfile } from "../types/migrant";
import MigrantProfileComponent from "../components/MigrantProfileComponent";
import LoadingSpinner from "../components/LoadingSpinner";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
// Helper function to calculate age at migration
const calculateAgeAtMigration = (birthDate: string | null | undefined, migrationDate: string | null | undefined): number => {
if (!birthDate || !migrationDate) return 0;
try {
const normalizedBirthDate = birthDate.includes('.') ? birthDate.split('.')[0] + 'Z' : birthDate;
const normalizedMigrationDate = migrationDate.includes('.') ? migrationDate.split('.')[0] + 'Z' : migrationDate;
const birthYear = new Date(normalizedBirthDate).getFullYear();
const migrationYear = new Date(normalizedMigrationDate).getFullYear();
// Simple year difference calculation
const age = migrationYear - birthYear;
return age >= 0 ? age : 0;
} catch (error) {
console.error(`Error calculating age: birth=${birthDate}, migration=${migrationDate}`, error);
return 0;
}
};
const MigrantProfilePage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [migrant, setMigrant] = useState<MigrantProfile | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
// Fetch migrant data when component mounts or ID changes
useEffect(() => {
const fetchMigrant = async () => {
const fetchMigrantData = async () => {
// Reset state when ID changes
setLoading(true);
setMigrant(null);
setError(null);
if (!id) {
setError('Missing migrant ID');
setLoading(false);
return;
}
try {
if (id) {
const data = await getMigrantById(id);
// Fetch migrant data from the backend using apiService
const data = await apiService.getRecordById(id);
if (data) {
setMigrant(data);
// Data successfully retrieved - convert API data to MigrantProfile
const migrantProfile: MigrantProfile = {
id: data.person_id || id,
firstName: data.christian_name || '',
lastName: data.surname || '',
middleName: '',
birthDate: data.date_of_birth || '',
birthPlace: data.place_of_birth || '',
ageAtMigration: calculateAgeAtMigration(data.date_of_birth, data.migration?.date_of_arrival_nt),
yearOfArrival: data.migration?.date_of_arrival_nt ?
new Date(data.migration.date_of_arrival_nt).getFullYear() : 0,
regionOfOrigin: data.place_of_birth || 'Unknown',
settlementLocation: data.residence?.town_or_city || 'Unknown',
occupation: data.occupation || 'Unknown',
deathDate: data.date_of_death || '',
deathPlace: data.residence?.home_at_death || '',
mainPhoto: '', // No photo URL in the API response
biography: data.additional_notes || '',
photos: [],
relatedMigrants: []
};
setMigrant(migrantProfile);
setError(null);
} else {
// Migrant not found, redirect to 404
navigate("/not-found");
// No data found for this ID
setMigrant(null);
setError(`No migrant found with ID: ${id}`);
}
}
} catch (error) {
console.error("Error fetching migrant:", error);
navigate("/not-found");
} catch (error: any) {
// Handle API errors
const errorMessage =
error.response?.data?.message ||
error.message ||
'An unexpected error occurred';
console.error('Error fetching migrant:', errorMessage);
setError(`Failed to load migrant data: ${errorMessage}`);
} finally {
setLoading(false);
}
};
fetchMigrant();
}, [id, navigate]);
fetchMigrantData();
}, [id, retryCount]); // retryCount allows manual retries
// Handle retry button click
const handleRetry = () => {
setRetryCount(prev => prev + 1);
};
// Loading state
if (loading) {
return <LoadingSpinner />;
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<h2 className="text-xl font-medium mb-2">Loading migrant profile...</h2>
<p className="text-gray-500">Retrieving data from the database</p>
</div>
);
}
// Error state
if (error) {
// Check if the error is specifically about not finding a migrant
const isNotFoundError = error.includes('No migrant found');
return (
<div className="max-w-3xl mx-auto p-8">
<Alert variant={isNotFoundError ? "default" : "destructive"} className={`mb-6 ${isNotFoundError ? 'border-amber-500' : ''}`}>
<AlertCircle className={`h-5 w-5 ${isNotFoundError ? 'text-amber-500' : ''}`} />
<AlertTitle>{isNotFoundError ? "Migrant Not Found" : "Error"}</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
<div className="flex flex-col items-center mt-8 mb-4">
{isNotFoundError && (
<div className="text-center mb-6">
<p className="text-lg mb-4">The migrant profile you're looking for could not be found in our database.</p>
<p className="text-gray-600">This might be because:</p>
<ul className="list-disc list-inside text-gray-600 mt-2 mb-4">
<li>The ID provided is incorrect</li>
<li>The record has been removed from the database</li>
<li>The record hasn't been added to the database yet</li>
</ul>
</div>
)}
<div className="flex gap-4 justify-center">
<Button onClick={() => navigate(-1)}>Go Back</Button>
{!isNotFoundError && (
<Button variant="outline" onClick={handleRetry}>Retry</Button>
)}
<Button variant="outline" onClick={() => window.location.href = '/search-test'}>Search Again</Button>
</div>
</div>
</div>
);
}
// No data state (should be caught by error state, but just in case)
if (!migrant) {
return null; // This should not happen as we redirect to 404 if migrant is null
return (
<div className="max-w-3xl mx-auto p-8 text-center">
<h2 className="text-2xl font-bold mb-4">Migrant Profile Not Found</h2>
<p className="mb-6">The migrant profile you're looking for could not be found.</p>
<Button onClick={() => navigate(-1)}>Return to Previous Page</Button>
</div>
);
}
// Render migrant profile when data is loaded successfully
return <MigrantProfileComponent migrant={migrant} />;
};

View File

@ -0,0 +1,66 @@
import axios from "axios";
import type { SearchResult, SearchParams } from "../types/search";
// Load base URL from environment
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
// Create Axios instance
const api = axios.create({
baseURL: API_BASE_URL,
headers: { "Content-Type": "application/json" },
});
// Request interceptor for logging
api.interceptors.request.use((request) => {
const fullUrl = `${request.baseURL || ""}${request.url || ""}`;
console.log("[API] Request URL:", fullUrl);
if (request.params) {
console.log("[API] Params:", request.params);
}
if (request.url?.includes("/migrants/")) {
const id = request.url.split("/").pop();
console.log("[API] Fetching migrant by ID:", id);
}
return request;
});
class ApiService {
/**
* Fetch a single migrant record by ID
*/
async getRecordById(id: string): Promise<SearchResult> {
const res = await api.get(`/api/migrants/${id}`);
return res.data.data;
}
/**
* Search for people based on filters.
* Filters out empty and "all" values.
*/
async searchPeople(params: SearchParams): Promise<SearchResult[]> {
const filtered = Object.fromEntries(
Object.entries(params)
.filter(([_, value]) => value && value !== "all")
.map(([key, value]) => [key, value.toString()])
);
if (Object.keys(filtered).length === 0) return [];
try {
const res = await api.get("/api/persons/search", {
params: { ...filtered, exactMatch: "true" },
});
return res.data.success && res.data.data ? res.data.data.data : [];
} catch (err) {
const message =
err instanceof Error ? err.message : "Unknown error during search";
throw new Error(`[API] Failed to search people: ${message}`);
}
}
}
export default new ApiService();

View File

@ -1,142 +0,0 @@
import type { MigrantProfile } from "../types/migrant"
import type { SearchParams, SearchResult } from "../types/search"
// Mock data for demonstration purposes
// In a real application, this would be fetched from an API
const migrants: MigrantProfile[] = [
{
id: "1",
firstName: "Antonio",
lastName: "Rossi",
middleName: "Giuseppe",
birthDate: "January 12, 1861",
birthPlace: "Palermo, Sicily",
ageAtMigration: 24,
yearOfArrival: 1885,
regionOfOrigin: "Sicily",
settlementLocation: "Darwin",
occupation: "Fisherman",
deathDate: "March 3, 1942",
deathPlace: "Darwin, Northern Territory",
mainPhoto: "/placeholder.jpg",
biography: `Antonio Giuseppe Rossi was born in Palermo, Sicily, in 1861 to a family of fishermen. Growing up along the Mediterranean coast, he learned the fishing trade from his father and grandfather, developing skills that would later serve him well in Australia.\n
In 1885, at the age of 24, Antonio left Italy seeking new opportunities. The fishing industry in Sicily was becoming increasingly competitive, and stories of Australia's abundant waters and opportunities had reached Italian shores. He arrived in Darwin with little more than his fishing knowledge and a determination to succeed.\n
Antonio quickly established himself in Darwin's small but growing fishing community. He was known for his innovative fishing techniques and his ability to predict weather patterns. Within five years of his arrival, he had saved enough money to purchase his own boat, which he named "Sicilia" in honor of his homeland.\n
In 1890, Antonio married Maria Bianchi, a fellow Italian immigrant from Calabria. Together they had six children, all of whom grew up helping with the family fishing business. Antonio and Maria were instrumental in establishing Darwin's Italian community, often hosting gatherings that celebrated Italian culture and traditions.\n
During World War II, Antonio, then in his eighties, witnessed the bombing of Darwin. Despite his age, he helped in the evacuation efforts, using his boat to transport people to safety. He passed away shortly after, in March 1942, leaving behind a legacy as one of Darwin's pioneering Italian settlers.`,
photos: [
{
url: "/placeholder.jpg",
caption: "Antonio on his fishing boat 'Sicilia'",
year: 1895,
},
{
url: "/placeholder.jpg",
caption: "Antonio and Maria on their wedding day",
year: 1890,
},
{
url: "/placeholder.jpg",
caption: "The Rossi family outside their Darwin home",
year: 1910,
},
],
relatedMigrants: [
{
id: "2",
firstName: "Maria",
lastName: "Bianchi",
relationship: "Wife",
photoUrl: "/placeholder.jpg",
},
],
},
{
id: "2",
firstName: "Maria",
lastName: "Bianchi",
ageAtMigration: 19,
yearOfArrival: 1892,
regionOfOrigin: "Calabria",
settlementLocation: "Alice Springs",
occupation: "Seamstress",
mainPhoto: "/placeholder.jpg",
},
{
id: "3",
firstName: "Giuseppe",
lastName: "Verdi",
ageAtMigration: 32,
yearOfArrival: 1901,
regionOfOrigin: "Veneto",
settlementLocation: "Katherine",
occupation: "Farmer",
mainPhoto: "/placeholder.jpg",
},
]
export const searchMigrants = async (params: SearchParams): Promise<SearchResult[]> => {
// In a real application, this would call an API endpoint
// For demonstration, we'll filter the mock data based on the search parameters
let results = migrants.map((migrant) => ({
id: migrant.id,
firstName: migrant.firstName,
lastName: migrant.lastName,
ageAtMigration: migrant.ageAtMigration,
yearOfArrival: migrant.yearOfArrival,
regionOfOrigin: migrant.regionOfOrigin,
settlementLocation: migrant.settlementLocation,
photoUrl: migrant.mainPhoto,
}))
// Filter by first name
if (params.firstName) {
results = results.filter((migrant) =>
migrant.firstName.toLowerCase().includes(params.firstName.toString().toLowerCase()),
)
}
// Filter by last name
if (params.lastName) {
results = results.filter((migrant) =>
migrant.lastName.toLowerCase().includes(params.lastName.toString().toLowerCase()),
)
}
// Filter by age at migration
if (params.ageAtMigration) {
results = results.filter((migrant) => migrant.ageAtMigration === Number(params.ageAtMigration))
}
// Filter by year of arrival
if (params.yearOfArrival) {
results = results.filter((migrant) => migrant.yearOfArrival === Number(params.yearOfArrival))
}
// Filter by region of origin
if (params.regionOfOrigin && params.regionOfOrigin !== "all") {
results = results.filter((migrant) => migrant.regionOfOrigin === params.regionOfOrigin)
}
// Filter by settlement location
if (params.settlementLocation && params.settlementLocation !== "all") {
results = results.filter((migrant) => migrant.settlementLocation === params.settlementLocation)
}
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 500))
return results
}
export const getMigrantById = async (id: string): Promise<MigrantProfile | null> => {
// In a real application, this would call an API endpoint
// For demonstration, we'll find the migrant in our mock data
const migrant = migrants.find((m) => m.id === id)
// Simulate API delay
await new Promise((resolve) => setTimeout(resolve, 300))
return migrant || null
}

View File

@ -0,0 +1,137 @@
import axios from "axios";
import type { SearchParams, SearchResult } from "@/types/search";
import type { MigrantProfile } from "@/types/migrant";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
class ApiService {
async getRecords(): Promise<SearchResult[]> {
return api.get('/api/persons/search').then((response) => response.data);
}
}
export const apiService = new ApiService();
export const searchPeople = async (params: SearchParams): Promise<SearchResult[]> => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value && value !== "all") {
query.append(key, value.toString());
}
});
try {
const response = await api.get(
`/api/persons/search?${query.toString()}`
);
const responseData = response.data;
if (responseData.success && responseData.data) {
return responseData.data.data.map((person: any) => ({
id: person.person_id || person.id,
firstName: person.christian_name,
lastName: person.surname,
yearOfArrival: person.migration?.date_of_arrival_nt ?
new Date(person.migration.date_of_arrival_nt).getFullYear() : 'Unknown',
ageAtMigration: person.age_at_migration || 'Unknown',
regionOfOrigin: person.place_of_birth || 'Unknown',
settlementLocation: person.residence?.suburb || 'Unknown',
occupation: person.occupation || 'Unknown',
photoUrl: person.photo_url || null
}));
}
return [];
} catch (error) {
console.error('Error searching people:', error);
throw error;
}
};
/**
* Retrieves detailed profile information for a specific migrant by ID
* @param id - The migrant's unique identifier
* @returns Promise containing the migrant's complete profile or null if not found
*/
export const getMigrantById = async (id: string): Promise<MigrantProfile | null> => {
try {
console.log(`Making API request to: ${API_BASE_URL}/api/migrants/${id}`);
const response = await axios.get(
`${API_BASE_URL}/api/migrants/${id}`
);
console.log('API Response:', response.status, response.statusText);
console.log('Response data:', response.data);
const responseData = response.data;
if (responseData.success && responseData.data) {
console.log('Successfully parsed response data');
const person = responseData.data;
return {
id: person.person_id || person.id,
firstName: person.christian_name,
lastName: person.surname,
middleName: person.middle_name,
birthDate: person.date_of_birth
? new Date(person.date_of_birth.split('.')[0] + 'Z').toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'Unknown',
birthPlace: person.place_of_birth,
ageAtMigration: person.age_at_migration ? Number(person.age_at_migration) : 0,
yearOfArrival: person.migration?.date_of_arrival_nt ?
new Date(person.migration.date_of_arrival_nt).getFullYear() : 0,
regionOfOrigin: person.place_of_birth || 'Unknown',
settlementLocation: person.residence?.suburb || 'Unknown',
occupation: person.occupation || 'Unknown',
deathDate: person.date_of_death
? new Date(person.date_of_death.split('.')[0] + 'Z').toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
: 'Unknown',
deathPlace: person.residence?.home_at_death || 'Unknown',
mainPhoto: person.photo_url || null,
biography: person.biography || '',
photos: person.photos || [],
relatedMigrants: person.related_persons || []
};
}
return null;
} catch (error: any) {
if (axios.isAxiosError(error)) {
console.error('Axios error in getMigrantById:', {
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
data: error.response?.data
});
// If the error is a 404, we can return null to indicate no migrant found
if (error.response?.status === 404) {
console.log('Migrant not found (404 response)');
return null;
}
} else {
console.error('Non-Axios error fetching migrant profile:', error);
}
throw error;
}
};

View File

@ -1,19 +1,84 @@
export interface SearchParams {
firstName: string
lastName: string
ageAtMigration: string | number
yearOfArrival: string | number
regionOfOrigin: string
settlementLocation: string
firstName?: string;
lastName?: string;
ageAtMigration?: string;
yearOfArrival?: string;
regionOfOrigin?: string;
settlementLocation?: string;
}
export interface SearchResult {
id: string
firstName: string
lastName: string
ageAtMigration: number
yearOfArrival: number
regionOfOrigin: string
settlementLocation: string
photoUrl?: string
// Pagination metadata structure
export interface Pagination {
total: number;
currentPage: number;
totalPages: number;
perPage: number;
}
// Migration details for a person
export interface Migration {
date_of_arrival_aus?: string;
date_of_arrival_nt?: string;
arrival_period?: string;
data_source?: string;
}
// Naturalization details for a person
export interface Naturalization {
certificate_number?: string;
date_of_naturalization?: string;
previous_nationality?: string;
place_of_naturalization?: string;
}
// Residence details for a person
export interface Residence {
town_or_city?: string;
home_at_death?: string;
}
// Family details for a person
export interface Family {
spouse_name?: string;
spouse_origin?: string;
number_of_children?: number;
names_of_children?: string;
additional_notes?: string;
}
// Internment details for a person
export interface Internment {
was_interned: boolean;
camp_name?: string;
date_of_internment?: string;
date_of_release?: string;
additional_notes?: string;
}
// A single search result representing a person record
export interface SearchResult {
person_id: string;
surname: string;
christian_name: string;
full_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;
migration: Migration;
naturalization: Naturalization;
residence: Residence;
family: Family;
internment: Internment;
}
// The overall search response from the API
export interface SearchResponse {
data: SearchResult[];
pagination: Pagination;
success: boolean;
message: string;
}

View File

@ -11,4 +11,14 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
server: {
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
// Don't rewrite the path since Laravel expects /api prefix
// rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});