This commit is contained in:
marki1212 2025-05-13 14:37:08 +08:00
commit 73db59a56e
43 changed files with 7371 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5421
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "italian-migrants-nt",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-select": "^2.2.4",
"@radix-ui/react-slot": "^1.2.2",
"@tailwindcss/vite": "^4.1.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.11.0",
"lucide-react": "^0.510.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.6.0",
"tailwind-merge": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^22.15.17",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.6",
"tw-animate-css": "^1.2.9",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5"
}
}

1
public/placeholder.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

0
src/App.css Normal file
View File

19
src/App.tsx Normal file
View File

@ -0,0 +1,19 @@
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";
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/migrant/:id" element={<MigrantProfilePage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Router>
);
}
export default App;

View File

@ -0,0 +1,52 @@
"use client";
import { motion } from "framer-motion";
import AnimatedImage from "@/components/ui/animated-image";
export default function HeroSection() {
return (
<section className="relative w-full h-[70vh] md:h-[80vh] overflow-hidden">
<div className="absolute inset-0 bg-black/60 z-10" />
<div className="relative h-full w-full">
<AnimatedImage
src="https://globalboston.bc.edu/wp-content/uploads/2016/07/06_01_012688.jpg"
alt="Historical image of Italian migrants in the Northern Territory"
fill
/>
</div>
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center text-center px-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2 }}
className="max-w-4xl"
>
{/* Top 4 bars */}
<div className="flex items-center justify-center mb-6 space-x-1">
<div className="h-1 w-12 bg-green-600" />
<div className="h-1 w-12 bg-white" />
<div className="h-1 w-12 bg-red-600" />
<div className="h-1 w-12 bg-gray-400" />
</div>
<h1 className="text-4xl md:text-6xl font-bold text-white mb-4 font-serif">
Italian Migration to the Northern Territory
</h1>
<p className="text-xl md:text-2xl text-white max-w-3xl italic">
Exploring the rich history and cultural legacy of Italian immigrants
in Australia's Northern Territory
</p>
{/* Bottom 4 bars */}
<div className="flex items-center justify-center mt-6 space-x-1">
<div className="h-1 w-12 bg-green-600" />
<div className="h-1 w-12 bg-white" />
<div className="h-1 w-12 bg-red-600" />
<div className="h-1 w-12 bg-gray-400" />
</div>
</motion.div>
</div>
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-black to-transparent z-10" />
</section>
);
}

View File

@ -0,0 +1,108 @@
interface HistoricalContextProps {
year: number;
}
const HistoricalContext = ({ year }: HistoricalContextProps) => {
// Function to get historical context based on the year
const getHistoricalContext = (year: number) => {
if (year < 1880) {
return {
period: "Early Settlement Period",
description:
"The earliest Italian migrants to the Northern Territory arrived during this period, primarily as individuals seeking opportunity in a new land. They often worked in mining, agriculture, or as merchants.",
events: [
"Early European settlement in the Northern Territory",
"Gold rushes in various parts of Australia",
"Establishment of Darwin (then called Palmerston)",
],
};
} else if (year < 1900) {
return {
period: "Late 19th Century",
description:
"This period saw increased Italian migration to Australia, with many arriving to work in agriculture, fishing, and construction. The Northern Territory's tropical climate was familiar to those from southern Italy.",
events: [
"Construction of the Overland Telegraph Line",
"Expansion of the pastoral industry",
"Growth of Darwin as a port city",
],
};
} else if (year < 1920) {
return {
period: "Federation and Early 20th Century",
description:
"Following Australian Federation in 1901, immigration policies became more restrictive, but Italians continued to arrive. Many came to join family members already established in the Territory.",
events: [
"Federation of Australia (1901)",
"World War I (1914-1918)",
"Development of the pearling industry",
],
};
} else if (year < 1940) {
return {
period: "Interwar Period",
description:
"The interwar period saw continued Italian migration, with many escaping economic hardship and the rise of fascism in Italy. Chain migration became common, with established migrants sponsoring relatives.",
events: [
"Great Depression (1929-1933)",
"Rise of fascism in Italy",
"Expansion of agriculture in the Northern Territory",
],
};
} else if (year < 1960) {
return {
period: "Post-World War II",
description:
"After World War II, Australia actively encouraged European migration. Many Italians came to Australia as part of assisted migration schemes, seeking to escape the devastation of post-war Italy.",
events: [
"World War II (1939-1945)",
"Beginning of Australia's 'Populate or Perish' policy",
"Bombing of Darwin (1942)",
"Reconstruction of Darwin",
],
};
} else if (year < 1980) {
return {
period: "Modern Migration",
description:
"This period saw continued Italian migration, though at a slower pace than previous decades. Many migrants were skilled workers or came to join established family networks.",
events: [
"End of the White Australia Policy",
"Cyclone Tracy devastates Darwin (1974)",
"Northern Territory granted self-government (1978)",
],
};
} else {
return {
period: "Contemporary Era",
description:
"Recent Italian migration to the Northern Territory has been limited, with most new arrivals being skilled professionals or family members of established migrants. The Italian community is now largely Australian-born descendants of earlier migrants.",
events: [
"Multiculturalism becomes official policy",
"Growth of tourism in the Northern Territory",
"Development of Darwin as a gateway to Asia",
],
};
}
};
const context = getHistoricalContext(year);
return (
<div className="card">
<div className="p-6">
<h2 className="text-xl font-bold mb-2">Historical Context</h2>
<h3 className="font-medium text-lg mb-2">{context.period}</h3>
<p className="text-sm mb-4">{context.description}</p>
<h4 className="font-medium text-sm mb-1">Key Events:</h4>
<ul className="text-sm list-disc pl-5 space-y-1">
{context.events.map((event, index) => (
<li key={index}>{event}</li>
))}
</ul>
</div>
</div>
);
};
export default HistoricalContext;

View File

@ -0,0 +1,83 @@
"use client";
import { motion } from "framer-motion";
import AnimatedImage from "@/components/ui/animated-image";
export default function IntroSection() {
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.2,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: { duration: 0.6 } },
};
return (
<section className="py-16 px-4 md:px-8 max-w-6xl mx-auto">
<motion.div
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: "-100px" }}
variants={containerVariants}
>
<motion.div variants={itemVariants} className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4 font-serif text-gray-800">
La Nostra Storia
</h2>
<div className="flex items-center justify-center">
<div className="h-1 w-12 bg-green-600" />
<div className="h-1 w-12 bg-white" />
<div className="h-1 w-12 bg-red-600" />
</div>
<p className="text-xl mt-4 text-gray-600 italic">Our History</p>
</motion.div>
<div className="grid md:grid-cols-2 gap-8 items-center">
<motion.div variants={itemVariants} className="space-y-4">
<p className="text-lg">
The history of Italian migration to the Northern Territory dates
back to the late 19th century, when the first Italian settlers
arrived seeking new opportunities and a better life.
</p>
<p className="text-lg">
These pioneers played a significant role in shaping the region's
development, contributing to industries such as agriculture,
fishing, construction, and mining.
</p>
<p className="text-lg">
Many Italian families established deep roots in the Territory,
creating vibrant communities that preserved their cultural
traditions while embracing their new Australian home.
</p>
<div className="pt-4 border-t border-gray-200">
<blockquote className="italic text-gray-700 pl-4 border-l-4 border-red-500">
"They brought with them not only their skills and work ethic,
but also their rich cultural heritage, culinary traditions, and
strong family values."
</blockquote>
</div>
</motion.div>
<motion.div variants={itemVariants}>
<div className="relative h-[300px] md:h-[400px] rounded-lg overflow-hidden shadow-lg border-8 border-white">
<AnimatedImage
src="https://www.lagazzettaitaliana.com/media/k2/items/cache/c62caf210f3cc558e679c3751b1975e6_XL.jpg"
alt="Italian family in the Northern Territory"
fill
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white p-3 text-sm">
Italian family settling in the Northern Territory, circa 1920s
</div>
</div>
</motion.div>
</div>
</motion.div>
</section>
);
}

View File

@ -0,0 +1,9 @@
const LoadingSpinner = () => {
return (
<div className="flex justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary-color"></div>
</div>
)
}
export default LoadingSpinner

View File

@ -0,0 +1,146 @@
import { Link } from "react-router-dom"
import type { MigrantProfile } from "../types/migrant"
import PhotoGallery from "./PhotoGallery"
import RelatedMigrants from "./RelatedMigrants"
import HistoricalContext from "./HistoricalContext"
interface MigrantProfileComponentProps {
migrant: MigrantProfile
}
const MigrantProfileComponent = ({ migrant }: MigrantProfileComponentProps) => {
return (
<main className="min-h-screen bg-gray-50 pb-16">
{/* Hero section with main photo */}
<div className="relative w-full h-[40vh] md:h-[50vh] overflow-hidden bg-gray-900">
<img
src={migrant.mainPhoto || "/placeholder.jpg"}
alt={`${migrant.firstName} ${migrant.lastName}`}
className="w-full h-full object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-6 md:p-10 text-white">
<h1 className="text-3xl md:text-5xl font-bold mb-2">
{migrant.firstName} {migrant.lastName}
</h1>
<p className="text-xl md:text-2xl opacity-90">
{migrant.yearOfArrival} {migrant.regionOfOrigin}, Italy {migrant.settlementLocation}, NT
</p>
</div>
</div>
{/* Back button */}
<div className="max-w-6xl mx-auto px-4 md:px-8 mt-6">
<Link to="/" className="btn btn-outline flex items-center gap-2 mb-6">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Back to Search
</Link>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main content - 2/3 width on desktop */}
<div className="lg:col-span-2 space-y-8">
{/* Biographical information */}
<div className="card">
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">Biographical Information</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-4 gap-x-8">
<div>
<h3 className="text-sm font-medium text-gray-500">Full Name</h3>
<p className="text-lg">
{migrant.firstName} {migrant.middleName ? migrant.middleName + " " : ""}
{migrant.lastName}
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Birth Date</h3>
<p className="text-lg">{migrant.birthDate || "Unknown"}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Birth Place</h3>
<p className="text-lg">{migrant.birthPlace || "Unknown"}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Age at Migration</h3>
<p className="text-lg">{migrant.ageAtMigration} years</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Year of Arrival</h3>
<p className="text-lg">{migrant.yearOfArrival}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Region of Origin</h3>
<p className="text-lg">{migrant.regionOfOrigin}, Italy</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Settlement Location</h3>
<p className="text-lg">{migrant.settlementLocation}, NT</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Occupation</h3>
<p className="text-lg">{migrant.occupation || "Unknown"}</p>
</div>
{migrant.deathDate && (
<>
<div>
<h3 className="text-sm font-medium text-gray-500">Date of Death</h3>
<p className="text-lg">{migrant.deathDate}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Place of Death</h3>
<p className="text-lg">{migrant.deathPlace || "Unknown"}</p>
</div>
</>
)}
</div>
</div>
</div>
{/* Life story */}
{migrant.biography && (
<div className="card">
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">Life Story</h2>
<div className="prose max-w-none">
{migrant.biography.split("\n").map((paragraph, index) => (
<p key={index} className="mb-4">
{paragraph}
</p>
))}
</div>
</div>
</div>
)}
{/* Photo gallery - only show if there are additional photos */}
{migrant.photos && migrant.photos.length > 0 && <PhotoGallery photos={migrant.photos} />}
</div>
{/* Sidebar - 1/3 width on desktop */}
<div className="space-y-8">
{/* Historical context */}
<HistoricalContext year={migrant.yearOfArrival} />
{/* Related migrants */}
{migrant.relatedMigrants && migrant.relatedMigrants.length > 0 && (
<RelatedMigrants migrants={migrant.relatedMigrants} />
)}
</div>
</div>
</div>
</main>
)
}
export default MigrantProfileComponent

View File

@ -0,0 +1,140 @@
"use client"
import { useState } from "react"
import type { Photo } from "../types/migrant"
interface PhotoGalleryProps {
photos: Photo[]
}
const PhotoGallery = ({ photos }: PhotoGalleryProps) => {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState<number | null>(null)
const openModal = (index: number) => {
setCurrentPhotoIndex(index)
document.body.style.overflow = "hidden"
}
const closeModal = () => {
setCurrentPhotoIndex(null)
document.body.style.overflow = "auto"
}
const handlePrevious = () => {
if (currentPhotoIndex === null) return
setCurrentPhotoIndex((prev) => (prev === 0 ? photos.length - 1 : prev - 1))
}
const handleNext = () => {
if (currentPhotoIndex === null) return
setCurrentPhotoIndex((prev) => (prev === photos.length - 1 ? 0 : prev + 1))
}
return (
<>
<div className="card">
<div className="p-6">
<h2 className="text-2xl font-bold mb-4">Photo Gallery</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{photos.map((photo, index) => (
<button
key={index}
className="relative h-40 w-full rounded-md overflow-hidden border border-gray-200 hover:opacity-90 transition-opacity"
onClick={() => openModal(index)}
>
<img
src={photo.url || "/placeholder.jpg"}
alt={photo.caption || `Photo ${index + 1}`}
className="w-full h-full object-cover"
/>
</button>
))}
</div>
</div>
</div>
{/* Modal */}
{currentPhotoIndex !== null && (
<div className="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
<div className="relative bg-black rounded-lg overflow-hidden max-w-4xl w-full max-h-[90vh]">
<button
className="absolute top-2 right-2 z-10 text-white bg-black/50 hover:bg-black/70 p-2 rounded-full"
onClick={closeModal}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
<div className="relative h-[70vh] w-full">
<img
src={photos[currentPhotoIndex].url || "/placeholder.jpg"}
alt={photos[currentPhotoIndex].caption || `Photo ${currentPhotoIndex + 1}`}
className="w-full h-full object-contain"
/>
</div>
{photos.length > 1 && (
<>
<button
className="absolute left-2 top-1/2 -translate-y-1/2 text-white bg-black/50 hover:bg-black/70 p-2 rounded-full"
onClick={handlePrevious}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 18l-6-6 6-6" />
</svg>
</button>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-white bg-black/50 hover:bg-black/70 p-2 rounded-full"
onClick={handleNext}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 18l6-6-6-6" />
</svg>
</button>
</>
)}
{photos[currentPhotoIndex].caption && (
<div className="absolute bottom-0 left-0 right-0 bg-black/70 text-white p-4">
<p>{photos[currentPhotoIndex].caption}</p>
{photos[currentPhotoIndex].year && (
<p className="text-sm text-gray-300">Year: {photos[currentPhotoIndex].year}</p>
)}
</div>
)}
</div>
</div>
)}
</>
)
}
export default PhotoGallery

View File

@ -0,0 +1,41 @@
import { Link } from "react-router-dom"
import type { RelatedMigrant } from "../types/migrant"
interface RelatedMigrantsProps {
migrants: RelatedMigrant[]
}
const RelatedMigrants = ({ migrants }: RelatedMigrantsProps) => {
return (
<div className="card">
<div className="p-6">
<h2 className="text-xl font-bold mb-4">Related Migrants</h2>
<div className="space-y-4">
{migrants.map((migrant) => (
<Link
key={migrant.id}
to={`/migrant/${migrant.id}`}
className="flex items-center gap-3 p-2 rounded-md hover:bg-gray-100 transition-colors"
>
<div className="relative h-12 w-12 rounded-full overflow-hidden flex-shrink-0">
<img
src={migrant.photoUrl || "/placeholder.jpg"}
alt={`${migrant.firstName} ${migrant.lastName}`}
className="w-full h-full object-cover"
/>
</div>
<div>
<h3 className="font-medium">
{migrant.firstName} {migrant.lastName}
</h3>
<p className="text-sm text-gray-500">{migrant.relationship}</p>
</div>
</Link>
))}
</div>
</div>
</div>
)
}
export default RelatedMigrants

View File

@ -0,0 +1,213 @@
"use client";
import { useState, type ChangeEvent, type FormEvent } from "react";
import type { SearchParams } from "../types/search";
// 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;
}
const SearchForm = ({ onSearch }: SearchFormProps) => {
const [formData, setFormData] = useState<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>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSearch(formData);
};
const handleReset = () => {
setFormData({
firstName: "",
lastName: "",
ageAtMigration: "",
yearOfArrival: "",
regionOfOrigin: "all",
settlementLocation: "all",
});
};
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
</label>
<input
id="firstName"
name="firstName"
type="text"
value={formData.firstName}
onChange={handleInputChange}
placeholder="Enter first name"
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
</label>
<select
id="regionOfOrigin"
name="regionOfOrigin"
value={formData.regionOfOrigin}
onChange={handleSelectChange}
className="w-full p-2 border border-gray-300 rounded-md"
>
<option value="all">All Regions</option>
{ITALIAN_REGIONS.map((region) => (
<option key={region} value={region}>
{region}
</option>
))}
</select>
</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">
<button
type="button"
onClick={handleReset}
className="border border-gray-400 text-gray-700 px-4 py-2 rounded hover:bg-gray-100 transition"
>
Reset
</button>
<button
type="submit"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition"
>
Search Records
</button>
</div>
</form>
);
};
export default SearchForm;

View File

@ -0,0 +1,67 @@
import { Link } from "react-router-dom"
import type { SearchResult } from "../types/search"
import LoadingSpinner from "./LoadingSpinner"
interface SearchResultsProps {
results: SearchResult[]
isLoading: boolean
}
const SearchResults = ({ results, isLoading }: SearchResultsProps) => {
if (isLoading) {
return (
<div className="text-center py-12">
<LoadingSpinner />
<p className="mt-4 text-gray-500">Searching records...</p>
</div>
)
}
if (results.length === 0) {
return (
<div className="text-center py-12">
<h3 className="text-2xl font-semibold mb-4">No Results Found</h3>
<p className="text-gray-500">Try adjusting your search criteria to find more records.</p>
</div>
)
}
return (
<div>
<h3 className="text-2xl font-semibold mb-6">Search Results ({results.length})</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{results.map((person) => (
<Link key={person.id} to={`/migrant/${person.id}`} className="block">
<div className="card h-full hover:shadow-lg transition-shadow">
<div className="relative h-48 w-full">
<img
src={person.photoUrl || "/placeholder.jpg"}
alt={`${person.firstName} ${person.lastName}`}
className="w-full h-full object-cover"
/>
</div>
<div className="p-4">
<h4 className="text-xl font-semibold">
{person.firstName} {person.lastName}
</h4>
<p className="text-sm text-gray-500">
Arrived {person.yearOfArrival} at age {person.ageAtMigration}
</p>
<div className="mt-4 space-y-2">
<p>
<span className="font-medium">From:</span> {person.regionOfOrigin}, Italy
</p>
<p>
<span className="font-medium">Settled in:</span> {person.settlementLocation}, NT
</p>
</div>
</div>
</div>
</Link>
))}
</div>
</div>
)
}
export default SearchResults

View File

@ -0,0 +1,39 @@
"use client"
import { useState } from "react"
import SearchForm from "./SearchForm"
import SearchResults from "./SearchResults"
import type { SearchParams, SearchResult } from "../types/search"
import { searchMigrants } from "../services/migrantService"
const SearchSection = () => {
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [hasSearched, setHasSearched] = useState(false)
const handleSearch = async (params: SearchParams) => {
setIsSearching(true)
try {
const results = await searchMigrants(params)
setSearchResults(results)
setHasSearched(true)
} catch (error) {
console.error("Error searching migrants:", error)
// In a real application, you would handle this error more gracefully
} finally {
setIsSearching(false)
}
}
return (
<section className="py-16 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto">
<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} />}
</div>
</section>
)
}
export default SearchSection

View File

@ -0,0 +1,45 @@
"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 || "/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,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,183 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
"use client"
import { useState, useEffect } from "react"
export const useImageLoader = (src: string) => {
const [isLoaded, setIsLoaded] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const img = new Image()
img.src = src
img.onload = () => {
setIsLoaded(true)
}
img.onerror = (e) => {
setError(e as unknown as Error)
}
return () => {
img.onload = null
img.onerror = null
}
}, [src])
return { isLoaded, error }
}

2
src/index.css Normal file
View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "tw-animate-css";

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

15
src/pages/HomePage.tsx Normal file
View File

@ -0,0 +1,15 @@
import HeroSection from "../components/HeroSection"
import IntroSection from "../components/IntroSection"
import SearchSection from "../components/SearchSection"
const HomePage = () => {
return (
<main>
<HeroSection />
<IntroSection />
<SearchSection />
</main>
)
}
export default HomePage

View File

@ -0,0 +1,50 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getMigrantById } from "../services/migrantService";
import type { MigrantProfile } from "../types/migrant";
import MigrantProfileComponent from "../components/MigrantProfileComponent";
import LoadingSpinner from "../components/LoadingSpinner";
const MigrantProfilePage = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [migrant, setMigrant] = useState<MigrantProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchMigrant = async () => {
try {
if (id) {
const data = await getMigrantById(id);
if (data) {
setMigrant(data);
} else {
// Migrant not found, redirect to 404
navigate("/not-found");
}
}
} catch (error) {
console.error("Error fetching migrant:", error);
navigate("/not-found");
} finally {
setLoading(false);
}
};
fetchMigrant();
}, [id, navigate]);
if (loading) {
return <LoadingSpinner />;
}
if (!migrant) {
return null; // This should not happen as we redirect to 404 if migrant is null
}
return <MigrantProfileComponent migrant={migrant} />;
};
export default MigrantProfilePage;

View File

@ -0,0 +1,18 @@
import { Link } from "react-router-dom"
const NotFoundPage = () => {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h2 className="text-2xl font-semibold mb-6">Record Not Found</h2>
<p className="text-gray-600 mb-8 max-w-md">
We couldn't find the migrant record you're looking for. It may have been removed or never existed.
</p>
<Link to="/" className="btn btn-primary">
Return to Homepage
</Link>
</div>
)
}
export default NotFoundPage

View File

@ -0,0 +1,142 @@
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
}

33
src/types/migrant.ts Normal file
View File

@ -0,0 +1,33 @@
export interface Photo {
url: string
caption?: string
year?: number
}
export interface RelatedMigrant {
id: string
firstName: string
lastName: string
relationship: string
photoUrl?: string
}
export interface MigrantProfile {
id: string
firstName: string
lastName: string
middleName?: string
birthDate?: string
birthPlace?: string
ageAtMigration: number
yearOfArrival: number
regionOfOrigin: string
settlementLocation: string
occupation?: string
deathDate?: string
deathPlace?: string
mainPhoto?: string
biography?: string
photos?: Photo[]
relatedMigrants?: RelatedMigrant[]
}

19
src/types/search.ts Normal file
View File

@ -0,0 +1,19 @@
export interface SearchParams {
firstName: string
lastName: string
ageAtMigration: string | number
yearOfArrival: string | number
regionOfOrigin: string
settlementLocation: string
}
export interface SearchResult {
id: string
firstName: string
lastName: string
ageAtMigration: number
yearOfArrival: number
regionOfOrigin: string
settlementLocation: string
photoUrl?: string
}

18
src/utils/formatters.ts Normal file
View File

@ -0,0 +1,18 @@
export const formatDate = (dateString: string | undefined): string => {
if (!dateString) return "Unknown"
// This is a simple formatter, but you could use libraries like date-fns for more complex formatting
return dateString
}
export const formatYear = (year: number | undefined): string => {
if (!year) return "Unknown"
return year.toString()
}
export const formatName = (firstName: string, middleName?: string, lastName?: string): string => {
let fullName = firstName
if (middleName) fullName += ` ${middleName}`
if (lastName) fullName += ` ${lastName}`
return fullName
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

31
tsconfig.app.json Normal file
View File

@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

13
tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@ -0,0 +1,14 @@
import path from "path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});